home · contact · privacy
Initial commit.
authorChristian Heller <c.heller@plomlompom.de>
Mon, 9 Nov 2020 23:40:35 +0000 (00:40 +0100)
committerChristian Heller <c.heller@plomlompom.de>
Mon, 9 Nov 2020 23:40:35 +0000 (00:40 +0100)
16 files changed:
config.json [new file with mode: 0644]
plomrogue/commands.py [new file with mode: 0644]
plomrogue/errors.py [new file with mode: 0644]
plomrogue/game.py [new file with mode: 0755]
plomrogue/io.py [new file with mode: 0644]
plomrogue/io_tcp.py [new file with mode: 0644]
plomrogue/io_websocket.py [new file with mode: 0644]
plomrogue/mapping.py [new file with mode: 0644]
plomrogue/misc.py [new file with mode: 0644]
plomrogue/parser.py [new file with mode: 0644]
plomrogue/tasks.py [new file with mode: 0644]
plomrogue/things.py [new file with mode: 0644]
requirements.txt [new file with mode: 0644]
rogue_chat.py [new file with mode: 0755]
rogue_chat_curses.py [new file with mode: 0755]
rogue_chat_nocanvas_monochrome.html [new file with mode: 0644]

diff --git a/config.json b/config.json
new file mode 100644 (file)
index 0000000..c594191
--- /dev/null
@@ -0,0 +1,19 @@
+{
+    "switch_to_chat": "t",
+    "switch_to_play": "p",
+    "switch_to_annotate": "m",
+    "switch_to_portal": "P",
+    "switch_to_study": "?",
+    "switch_to_edit": "m",
+    "flatten": "F",
+    "hex_move_upleft": "w",
+    "hex_move_upright": "e",
+    "hex_move_right": "d",
+    "hex_move_downright": "x",
+    "hex_move_downleft": "y",
+    "hex_move_left": "a",
+    "square_move_up": "w",
+    "square_move_left": "a",
+    "square_move_down": "s",
+    "square_move_right": "d"
+}
diff --git a/plomrogue/commands.py b/plomrogue/commands.py
new file mode 100644 (file)
index 0000000..a80731d
--- /dev/null
@@ -0,0 +1,95 @@
+from plomrogue.misc import quote
+from plomrogue.errors import GameError
+from plomrogue.mapping import YX, MapGeometrySquare, MapGeometryHex
+
+
+
+def cmd_ALL(game, msg, connection_id):
+    if not connection_id in game.sessions:
+        raise GameError('need to be logged in for this')
+    t = game.get_thing(game.sessions[connection_id], False)
+    game.io.send('CHAT ' + quote(t.nickname + ': ' + msg))
+cmd_ALL.argtypes = 'string'
+
+# TOOD split into two commands
+def cmd_LOGIN(game, nick, connection_id):
+    for t in [t for t in game.things if t.type_ == 'player' and t.nickname == nick]:
+        raise GameError('name already in use')
+    if connection_id in game.sessions:
+        t_id = game.sessions[connection_id]
+        t = game.get_thing(t_id, False)
+        old_nick = t.nickname
+        t.nickname = nick
+        game.io.send('CHAT ' + quote(old_nick + ' renamed themselves to ' + nick))
+    else:
+        t = game.thing_types['player'](game)
+        t.position = YX(game.map.size.y // 2, game.map.size.x // 2)
+        game.things += [t]  # TODO refactor into Thing.__init__?
+        game.sessions[connection_id] = t.id_
+        game.io.send('LOGIN_OK', connection_id)
+        t.nickname = nick
+        game.io.send('CHAT ' + quote(t.nickname + ' entered the map.'))
+        game.io.send('PLAYER_ID %s' % t.id_, connection_id)
+    game.changed = True
+cmd_LOGIN.argtypes = 'string'
+
+def cmd_GET_GAMESTATE(game, connection_id):
+    game.send_gamestate(connection_id)
+cmd_GET_GAMESTATE.argtypes = ''
+
+def cmd_QUERY(game, target_nick, msg, connection_id):
+    if not connection_id in game.sessions:
+        raise GameError('can only query when logged in')
+    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('CHAT ' + quote(source_nick+ '->' + target_nick + ': ' + msg), c_id)
+                game.io.send('CHAT ' + quote(source_nick+ '->' + target_nick + ': ' + msg), connection_id)
+                return
+        raise GameError('target user offline')
+    raise GameError('can only query with registered nicknames')
+cmd_QUERY.argtypes = 'string string'
+
+def cmd_PING(game, connection_id):
+    game.io.send('PONG', connection_id)
+cmd_PING.argtypes = ''
+
+def cmd_TURN(game, n):
+    game.turn = n
+cmd_TURN.argtypes = 'int:nonneg'
+
+def cmd_ANNOTATE(game, yx, msg, connection_id):
+    if msg == ' ':
+        if yx in game.annotations:
+            del game.annotations[yx]
+    else:
+        game.annotations[yx] = msg
+    game.changed = True
+cmd_ANNOTATE.argtypes = 'yx_tuple:nonneg string'
+
+def cmd_PORTAL(game, yx, msg, connection_id):
+    if msg == ' ':
+        if yx in game.portals:
+            del game.portals[yx]
+    else:
+        game.portals[yx] = msg
+    game.changed = True
+cmd_PORTAL.argtypes = 'yx_tuple:nonneg string'
+
+def cmd_GET_ANNOTATION(game, yx, connection_id):
+    annotation = '(none)';
+    if yx in game.annotations:
+        annotation = game.annotations[yx]
+    game.io.send('ANNOTATION %s %s' % (yx, quote(annotation)))
+cmd_GET_ANNOTATION.argtypes = 'yx_tuple:nonneg'
+
+def cmd_MAP_LINE(game, y, line):
+    game.map.set_line(y, line)
+cmd_MAP_LINE.argtypes = 'int:nonneg string'
+
+def cmd_MAP(game, geometry, size):
+    map_geometry_class = globals()['MapGeometry' + geometry]
+    game.new_world(map_geometry_class(size))
+cmd_MAP.argtypes = 'string:map_geometry yx_tuple:pos'
diff --git a/plomrogue/errors.py b/plomrogue/errors.py
new file mode 100644 (file)
index 0000000..b98f8f6
--- /dev/null
@@ -0,0 +1,14 @@
+class ArgError(Exception):
+    pass
+
+
+class GameError(Exception):
+    pass
+
+
+class PlayError(Exception):
+    pass
+
+
+class BrokenSocketConnection(Exception):
+    pass
diff --git a/plomrogue/game.py b/plomrogue/game.py
new file mode 100755 (executable)
index 0000000..68bcb65
--- /dev/null
@@ -0,0 +1,203 @@
+from plomrogue.tasks import (Task_WAIT, Task_MOVE, Task_WRITE,
+                             Task_FLATTEN_SURROUNDINGS)
+from plomrogue.errors import GameError, PlayError
+from plomrogue.io import GameIO
+from plomrogue.misc import quote
+from plomrogue.things import Thing, ThingPlayer
+from plomrogue.mapping import YX, MapGeometrySquare, Map
+
+
+
+class GameBase:
+
+    def __init__(self):
+        self.turn = 0
+        self.things = []
+        self.map_geometry = MapGeometrySquare(YX(24, 40))
+        self.commands = {}
+
+    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
+
+    def register_command(self, command):
+        prefix = 'cmd_'
+        if not command.__name__.startswith(prefix):
+           raise GameError('illegal command object name: %s' % command.__name__)
+        command_name = command.__name__[len(prefix):]
+        self.commands[command_name] = command
+
+
+
+import os
+class Game(GameBase):
+
+    def __init__(self, save_file, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self.changed = True
+        self.io = GameIO(self, save_file)
+        self.tasks = {}
+        self.thing_type = Thing
+        self.thing_types = {'player': ThingPlayer}
+        self.sessions = {}
+        self.map = Map(self.map_geometry.size)
+        self.annotations = {}
+        self.portals = {}
+        if os.path.exists(self.io.save_file):
+            if not os.path.isfile(self.io.save_file):
+                raise GameError('save file path refers to non-file')
+
+    def register_task(self, task):
+        prefix = 'Task_'
+        if not task.__name__.startswith(prefix):
+           raise GameError('illegal task object name: %s' % task.__name__)
+        task_name = task.__name__[len(prefix):]
+        self.tasks[task_name] = task
+
+    def read_savefile(self):
+        if os.path.exists(self.io.save_file):
+            with open(self.io.save_file, 'r') as f:
+                lines = f.readlines()
+            for i in range(len(lines)):
+                line = lines[i]
+                print("FILE INPUT LINE %5s: %s" % (i, line), end='')
+                self.io.handle_input(line, god_mode=True)
+
+    def get_string_options(self, string_option_type):
+        import string
+        if string_option_type == 'direction':
+            return self.map_geometry.get_directions()
+        elif string_option_type == 'char':
+            return [c for c in
+                    string.digits + string.ascii_letters + string.punctuation + ' ']
+        elif string_option_type == 'map_geometry':
+            return ['Hex', 'Square']
+        return None
+
+    def get_map_geometry_shape(self):
+        return self.map_geometry.__class__.__name__[len('MapGeometry'):]
+
+    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))
+            if hasattr(thing, 'nickname'):
+                self.io.send('THING_NAME %s %s' % (thing.id_, quote(t.nickname)))
+
+        self.io.send('TURN ' + str(self.turn))
+        for t in self.things:
+            send_thing(t)
+        self.io.send('MAP %s %s %s' % (self.get_map_geometry_shape(),
+                                       self.map_geometry.size, quote(self.map.terrain)))
+        for yx in self.portals:
+            self.io.send('PORTAL %s %s' % (yx, quote(self.portals[yx])))
+        self.io.send('GAME_STATE_COMPLETE')
+
+    def run_tick(self):
+        to_delete = []
+        for connection_id in self.sessions:
+            connection_id_found = False
+            for server in self.io.servers:
+                if connection_id in server.clients:
+                    connection_id_found = True
+                    break
+            if not connection_id_found:
+                t = self.get_thing(self.sessions[connection_id], create_unfound=False)
+                if hasattr(t, 'nickname'):
+                    self.io.send('CHAT ' + quote(t.nickname + ' left the map.'))
+                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)
+                except PlayError as e:
+                    for connection_id in [c_id for c_id in self.sessions
+                                          if self.sessions[c_id] == t.id_]:
+                        self.io.send('PLAY_ERROR ' + quote(str(e)), connection_id)
+        if self.changed:
+            self.turn += 1
+            self.send_gamestate()
+            self.changed = False
+            self.save()
+
+    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
+
+    def save(self):
+
+      def write(f, msg):
+          f.write(msg + '\n')
+
+      with open(self.io.save_file, 'w') as f:
+          # TODO: save tasks
+          write(f, 'TURN %s' % self.turn)
+          map_geometry_shape = self.get_map_geometry_shape()
+          write(f, 'MAP %s %s' % (map_geometry_shape, self.map_geometry.size,))
+          for y, line in self.map.lines():
+              write(f, 'MAP_LINE %5s %s' % (y, quote(line)))
+          for yx in self.annotations:
+              write(f, 'ANNOTATE %s %s' % (yx, quote(self.annotations[yx])))
+          for yx in self.portals:
+              write(f, 'PORTAL %s %s' % (yx, quote(self.portals[yx])))
+
+    def new_world(self, map_geometry):
+        self.map_geometry = map_geometry
+        self.map = Map(self.map_geometry.size)
+        self.annotations = {}
diff --git a/plomrogue/io.py b/plomrogue/io.py
new file mode 100644 (file)
index 0000000..8e0f9bf
--- /dev/null
@@ -0,0 +1,100 @@
+import queue
+import threading
+import inspect
+
+
+
+class GameIO():
+
+    def __init__(self, game, save_file='savefile'):
+        from plomrogue.parser import Parser
+        self.parser = Parser(game)
+        self.game = game
+        self.save_file = save_file
+        self.servers = []
+
+    def loop(self, q):
+        """Handle commands coming through queue q, run game, send results back."""
+        while True:
+            try:
+                command, connection_id = q.get(timeout=0.001)
+                self.handle_input(connection_id, command)
+            except queue.Empty:
+                self.game.run_tick()
+
+    def start_loop(self):
+        """Start game loop, set up self.queue to communicate with it.
+
+        The game loop works sequentially through game commands received
+        via self.queue from connected servers' clients."""
+        self.queue = queue.Queue()
+        c = threading.Thread(target=self.loop, args=(self.queue,))
+        c.start()
+
+    def start_server(self, port, server_class, certfile=None, keyfile=None):
+        """Start server of server_class in talk with game loop.
+
+        The server communicates with the game loop via self.queue.
+        """
+        if 'certfile' in list(inspect.signature(server_class.__init__).parameters):
+            server = server_class(self.queue, port, certfile=certfile, keyfile=keyfile)
+        else:
+            server = server_class(self.queue, port)
+        self.servers += [server]
+        c = threading.Thread(target=server.serve_forever)
+        c.start()
+
+    def handle_input(self, input_, connection_id=None, god_mode=False):
+        """Process input_ to command grammar, call command handler if found.
+
+        Command handlers that have no connectin_i argument in their
+        signature will only be called if god_mode is set.
+
+        """
+        from plomrogue.errors import GameError, ArgError, PlayError
+        from plomrogue.misc import quote
+
+        def answer(connection_id, msg):
+            if connection_id:
+                self.send(msg, connection_id)
+            else:
+                print(msg)
+
+        try:
+            command, args = self.parser.parse(input_)
+            if command is None:
+                answer(connection_id, 'UNHANDLED_INPUT')
+            else:
+                if 'connection_id' in list(inspect.signature(command).parameters):
+                    command(*args, connection_id=connection_id)
+                elif god_mode:
+                    command(*args)
+                    #if store and not hasattr(command, 'dont_save'):
+                    #    with open(self.game_file_name, 'a') as f:
+                    #        f.write(input_ + '\n')
+        except ArgError as e:
+            answer(connection_id, 'ARGUMENT_ERROR ' + quote(str(e)))
+        except PlayError as e:
+            answer(connection_id, 'PLAY_ERROR ' + quote(str(e)))
+        except GameError as e:
+            answer(connection_id, 'GAME_ERROR ' + quote(str(e)))
+
+    def send(self, msg, connection_id=None):
+        """Send message msg to servers' client(s).
+
+        If a specific client is identified by connection_id, only
+        sends msg to that one. Else, sends it to all client sessions.
+
+        """
+        if connection_id:
+            for server in self.servers:
+                 if connection_id in server.clients:
+                    client = server.clients[connection_id]
+                    client.put(msg)
+        else:
+            for c_id in self.game.sessions:
+                for server in self.servers:
+                    if c_id in server.clients:
+                        client = server.clients[c_id]
+                        client.put(msg)
+                        break
diff --git a/plomrogue/io_tcp.py b/plomrogue/io_tcp.py
new file mode 100644 (file)
index 0000000..09b9db1
--- /dev/null
@@ -0,0 +1,201 @@
+import socketserver
+
+
+# Avoid "Address already in use" errors.
+socketserver.TCPServer.allow_reuse_address = True
+
+
+
+from plomrogue.errors import BrokenSocketConnection
+class PlomSocket:
+
+    def __init__(self, socket):
+        self.socket = socket
+
+    def send(self, message, silent_connection_break=False):
+        """Send via self.socket, encoded/delimited as way recv() expects.
+
+        In detail, all \ and $ in message are escaped with prefixed \,
+        and an unescaped $ is appended as a message delimiter. Then,
+        socket.send() is called as often as necessary to ensure
+        message is sent fully, as socket.send() due to buffering may
+        not send all of it right away.
+
+        Assuming socket is blocking, it's rather improbable that
+        socket.send() will be partial / return a positive value less
+        than the (byte) length of msg – but not entirely out of the
+        question. See: - <http://stackoverflow.com/q/19697218> -
+        <http://stackoverflow.com/q/2618736> -
+        <http://stackoverflow.com/q/8900474>
+
+        This also handles a socket.send() return value of 0, which
+        might be possible or not (?) for blocking sockets: -
+        <http://stackoverflow.com/q/34919846>
+
+        """
+        escaped_message = ''
+        for char in message:
+            if char in ('\\', '$'):
+                escaped_message += '\\'
+            escaped_message += char
+        escaped_message += '$'
+        data = escaped_message.encode()
+        totalsent = 0
+        while totalsent < len(data):
+            socket_broken = False
+            try:
+                sent = self.socket.send(data[totalsent:])
+                socket_broken = sent == 0
+                totalsent = totalsent + sent
+            except OSError as err:
+                if err.errno == 9:  # "Bad file descriptor", when connection broken
+                    socket_broken = True
+                else:
+                    raise err
+            if socket_broken and not silent_connection_break:
+                raise BrokenSocketConnection
+
+    def recv(self):
+        """Get full send()-prepared message from self.socket.
+
+        In detail, socket.recv() is looped over for sequences of bytes
+        that can be decoded as a Unicode string delimited by an
+        unescaped $, with \ and $ escapable by \. If a sequence of
+        characters that ends in an unescaped $ cannot be decoded as
+        Unicode, None is returned as its representation. Stop once
+        socket.recv() returns nothing.
+
+        Under the hood, the TCP stack receives packets that construct
+        the input payload in an internal buffer; socket.recv(BUFSIZE)
+        pops up to BUFSIZE bytes from that buffer, without knowledge
+        either about the input's segmentation into packets, or whether
+        the input is segmented in any other meaningful way; that's why
+        we do our own message segmentation with $ as a delimiter.
+
+        """
+        esc = False
+        data = b''
+        msg = b''
+        while True:
+            try:
+                data = self.socket.recv(1024)
+            except OSError as err:
+                if err.errno == 9:  # "Bad file descriptor", when connection broken
+                    raise BrokenSocketConnection
+            if 0 == len(data):
+                break
+            for c in data:
+                if esc:
+                    msg += bytes([c])
+                    esc = False
+                elif chr(c) == '\\':
+                    esc = True
+                elif chr(c) == '$':
+                    try:
+                        yield msg.decode()
+                    except UnicodeDecodeError:
+                        yield None
+                    msg = b''
+                else:
+                    msg += bytes([c])
+
+
+
+class PlomSocketSSL(PlomSocket):
+
+    def __init__(self, *args, certfile, keyfile, **kwargs):
+        import ssl
+        super().__init__(*args, **kwargs)
+        self.send('NEED_SSL')
+        self.socket = ssl.wrap_socket(self.socket, server_side=True,
+                                      certfile=certfile, keyfile=keyfile)
+
+
+
+class IO_Handler(socketserver.BaseRequestHandler):
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+    def handle(self):
+        """Move messages between network socket and game IO loop via queues.
+
+        On start (a new connection from client to server), sets up a
+        new queue, sends it via self.server.queue_out to the game IO
+        loop thread, and from then on receives messages to send back
+        from the game IO loop via that new queue.
+
+        At the same time, loops over socket's recv to get messages
+        from the outside into the game IO loop by way of
+        self.server.queue_out into the game IO. Ends connection once a
+        'QUIT' message is received from socket, and then also calls
+        for a kill of its own queue.
+
+        """
+
+        def send_queue_messages(plom_socket, queue_in, thread_alive):
+            """Send messages via socket from queue_in while thread_alive[0]."""
+            while thread_alive[0]:
+                try:
+                    msg = queue_in.get(timeout=1)
+                except queue.Empty:
+                    continue
+                plom_socket.send(msg, True)
+
+        import uuid
+        import queue
+        import threading
+        if self.server.socket_class == PlomSocketSSL:
+            plom_socket = self.server.socket_class(self.request,
+                                                   certfile=self.server.certfile,
+                                                   keyfile=self.server.keyfile)
+        else:
+            plom_socket = self.server.socket_class(self.request)
+        print('CONNECTION FROM:', str(self.client_address))
+        connection_id = uuid.uuid4()
+        queue_in = queue.Queue()
+        self.server.clients[connection_id] = queue_in
+        thread_alive = [True]
+        t = threading.Thread(target=send_queue_messages,
+                             args=(plom_socket, queue_in, thread_alive))
+        t.start()
+        for message in plom_socket.recv():
+            if message is None:
+                plom_socket.send('BAD MESSAGE', True)
+            elif 'QUIT' == message:
+                plom_socket.send('BYE', True)
+                break
+            else:
+                self.server.queue_out.put((connection_id, message))
+        del self.server.clients[connection_id]
+        thread_alive[0] = False
+        print('CONNECTION CLOSED FROM:', str(self.client_address))
+        plom_socket.socket.close()
+
+
+
+class PlomTCPServer(socketserver.ThreadingTCPServer):
+    """Bind together threaded IO handling server and message queue.
+
+    By default this only serves to localhost connections.  For remote
+    connections, consider using PlomTCPServerSSL for more security,
+    which defaults to serving all connections.
+
+    """
+
+    def __init__(self, queue, port, host='127.0.0.1', *args, **kwargs):
+        super().__init__((host, port), IO_Handler, *args, **kwargs)
+        self.socket_class = PlomSocket
+        self.queue_out = queue
+        self.daemon_threads = True  # Else, server's threads have daemon=False.
+        self.clients = {}
+
+
+
+class PlomTCPServerSSL(PlomTCPServer):
+
+    def __init__(self, *args, certfile, keyfile, **kwargs):
+        super().__init__(*args, host='0.0.0.0', **kwargs)
+        self.certfile = certfile
+        self.keyfile = keyfile
+        self.socket_class = PlomSocketSSL
diff --git a/plomrogue/io_websocket.py b/plomrogue/io_websocket.py
new file mode 100644 (file)
index 0000000..0a74f4e
--- /dev/null
@@ -0,0 +1,46 @@
+from SimpleWebSocketServer import SimpleWebSocketServer, WebSocket
+
+
+
+class PlomWebSocket(WebSocket):
+
+    def handleMessage(self):
+        if self.data == 'QUIT':
+            self.sendMessage('BYE')
+            self.close()
+        else:
+            for connection_id in self.server.clients:
+                if self.server.clients[connection_id] == self:
+                    self.server.queue.put((connection_id, self.data))
+                    break
+
+    def handleConnected(self):
+        import uuid
+        print('CONNECTION FROM:', self.address)
+        connection_id = uuid.uuid4()
+        self.server.clients[connection_id] = self
+
+    def handleClose(self):
+        print('CONNECTION CLOSED FROM:', self.address)
+        for connection_id in self.server.clients:
+            if self.server.clients[connection_id] == self:
+                del self.server.clients[connection_id]
+
+    def put(self, msg):
+        self.sendMessage(msg)
+
+
+
+class PlomWebSocketServer(SimpleWebSocketServer):
+
+    def __init__(self, queue, port, *args, **kwargs):
+        super().__init__('', port, PlomWebSocket)
+        self.queue = queue
+        self.clients = {}
+
+    def serve_forever(self):
+        self.serveforever()
+
+    def server_close(self):
+        self.close()
+
diff --git a/plomrogue/mapping.py b/plomrogue/mapping.py
new file mode 100644 (file)
index 0000000..e0a59d8
--- /dev/null
@@ -0,0 +1,136 @@
+import collections
+from plomrogue.errors import ArgError
+
+
+
+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)
+
+
+
+class MapGeometry():
+
+    def __init__(self, size):
+        self.size = size
+
+    def get_directions(self):
+        directions = []
+        for name in dir(self):
+            if name[:5] == 'move_':
+                directions += [name[5:]]
+        return directions
+
+    def get_neighbors(self, pos):
+        neighbors = {}
+        for direction in self.get_directions():
+            neighbors[direction] = self.move(pos, direction)
+        return neighbors
+
+    def move(self, start_pos, direction):
+        mover = getattr(self, 'move_' + direction)
+        target = mover(start_pos)
+        if target.y < 0 or target.x < 0 or \
+                target.y >= self.size.y or target.x >= self.size.x:
+            return None
+        return target
+
+
+
+class MapGeometryWithLeftRightMoves(MapGeometry):
+
+    def move_LEFT(self, start_pos):
+        return YX(start_pos.y, start_pos.x - 1)
+
+    def move_RIGHT(self, start_pos):
+        return YX(start_pos.y, start_pos.x + 1)
+
+
+
+class MapGeometrySquare(MapGeometryWithLeftRightMoves):
+
+    def move_UP(self, start_pos):
+        return YX(start_pos.y - 1, start_pos.x)
+
+    def move_DOWN(self, start_pos):
+        return YX(start_pos.y + 1, start_pos.x)
+
+
+
+class MapGeometryHex(MapGeometryWithLeftRightMoves):
+
+    def move_UPLEFT(self, start_pos):
+        start_indented = start_pos.y % 2
+        if start_indented:
+            return YX(start_pos.y - 1, start_pos.x)
+        else:
+            return YX(start_pos.y - 1, start_pos.x - 1)
+
+    def move_UPRIGHT(self, start_pos):
+        start_indented = start_pos.y % 2
+        if start_indented:
+            return YX(start_pos.y - 1, start_pos.x + 1)
+        else:
+            return YX(start_pos.y - 1, start_pos.x)
+
+    def move_DOWNLEFT(self, start_pos):
+        start_indented = start_pos.y % 2
+        if start_indented:
+            return YX(start_pos.y + 1, start_pos.x)
+        else:
+            return YX(start_pos.y + 1, start_pos.x - 1)
+
+    def move_DOWNRIGHT(self, start_pos):
+        start_indented = start_pos.y % 2
+        if start_indented:
+            return YX(start_pos.y + 1, start_pos.x + 1)
+        else:
+            return YX(start_pos.y + 1, start_pos.x)
+
+
+
+class Map():
+
+    def __init__(self, map_size):
+        self.size = map_size
+        self.terrain = '.' * self.size_i
+
+    def __getitem__(self, yx):
+        return self.terrain[self.get_position_index(yx)]
+
+    def __setitem__(self, yx, c):
+        pos_i = self.get_position_index(yx)
+        if type(c) == str:
+            self.terrain = self.terrain[:pos_i] + c + self.terrain[pos_i + 1:]
+        else:
+            self.terrain[pos_i] = c
+
+    @property
+    def size_i(self):
+        return self.size.y * self.size.x
+
+    def set_line(self, y, line):
+        height_map = self.size.y
+        width_map = self.size.x
+        if y >= height_map:
+            raise ArgError('too large row number %s' % y)
+        width_line = len(line)
+        if width_line != width_map:
+            raise ArgError('map line width %s unequal map width %s' % (width_line, width_map))
+        self.terrain = self.terrain[:y * width_map] + line +\
+                       self.terrain[(y + 1) * width_map:]
+
+    def get_position_index(self, yx):
+        return yx.y * self.size.x + yx.x
+
+    def lines(self):
+        width = self.size.x
+        for y in range(self.size.y):
+            yield (y, self.terrain[y * width:(y + 1) * width])
diff --git a/plomrogue/misc.py b/plomrogue/misc.py
new file mode 100644 (file)
index 0000000..a3f7298
--- /dev/null
@@ -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/plomrogue/parser.py b/plomrogue/parser.py
new file mode 100644 (file)
index 0000000..5782d69
--- /dev/null
@@ -0,0 +1,128 @@
+import unittest
+from plomrogue.errors import ArgError
+from plomrogue.mapping import YX
+
+
+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_yx_tuple(self, yx_string, range_=None):
+        """Parse yx_string as yx_tuple, return result.
+
+        The range_ argument may be 'nonneg' (non-negative, including
+        0) or 'pos' (positive, excluding 0).
+
+        """
+
+        def get_axis_position_from_argument(axis, token):
+            if len(token) < 3 or token[:2] != axis + ':' or \
+                    not (token[2:].isdigit() or token[2] == '-'):
+                raise ArgError('Non-int arg for ' + axis + ' position.')
+            n = int(token[2:])
+            if n < 1 and range_ == 'pos':
+                raise ArgError('Arg for ' + axis + ' position < 1.')
+            elif n < 0 and range_ == 'nonneg':
+                raise ArgError('Arg for ' + axis + ' position < 0.')
+            return n
+
+        tokens = yx_string.split(',')
+        if len(tokens) != 2:
+            raise ArgError('Wrong number of yx-tuple arguments.')
+        y = get_axis_position_from_argument('Y', tokens[0])
+        x = get_axis_position_from_argument('X', tokens[1])
+        return YX(y, x)
+
+    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 == 'int:nonneg':
+                if not arg.isdigit():
+                    raise ArgError('Argument must be non-negative integer.')
+                args += [int(arg)]
+            elif tmpl == 'yx_tuple:nonneg':
+                args += [self.parse_yx_tuple(arg, 'nonneg')]
+            elif tmpl == 'yx_tuple:pos':
+                args += [self.parse_yx_tuple(arg, 'pos')]
+            elif 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/plomrogue/tasks.py b/plomrogue/tasks.py
new file mode 100644 (file)
index 0000000..99bb41f
--- /dev/null
@@ -0,0 +1,71 @@
+from plomrogue.errors import PlayError
+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):
+    todo = 1
+    argtypes = 'string:direction'
+
+    def get_move_target(self):
+        return self.thing.game.map_geometry.move(self.thing.position,
+                                                 self.args[0])
+
+    def check(self):
+        test_pos = self.get_move_target()
+        if test_pos is None:
+            raise PlayError('would move out of map')
+        elif test_pos in [t.position for t in self.thing.game.things]:
+            raise PlayError('would collide with other things')
+        elif self.thing.game.map[test_pos] != '.':
+            raise PlayError('would move into illegal territory')
+
+    def do(self):
+        self.thing.position = self.get_move_target()
+
+
+
+class Task_WRITE(Task):
+    todo = 1
+    argtypes = 'string:char'
+
+    def check(self):
+        pass
+
+    def do(self):
+        self.thing.game.map[self.thing.position] = self.args[0]
+
+
+
+class Task_FLATTEN_SURROUNDINGS(Task):
+    todo = 10
+
+    def check(self):
+        pass
+
+    def do(self):
+        self.thing.game.map[self.thing.position] = '.'
+        for yx in self.thing.game.map_geometry.get_neighbors(self.thing.position).values():
+            if yx is not None:
+                self.thing.game.map[yx] = '.'
diff --git a/plomrogue/things.py b/plomrogue/things.py
new file mode 100644 (file)
index 0000000..0e4af34
--- /dev/null
@@ -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/requirements.txt b/requirements.txt
new file mode 100644 (file)
index 0000000..7d7d093
--- /dev/null
@@ -0,0 +1,2 @@
+SimpleWebSocketServer
+ws4py
diff --git a/rogue_chat.py b/rogue_chat.py
new file mode 100755 (executable)
index 0000000..026fcdf
--- /dev/null
@@ -0,0 +1,34 @@
+#!/usr/bin/env python3
+from plomrogue.game import Game
+from plomrogue.io_websocket import PlomWebSocketServer
+from plomrogue.io_tcp import PlomTCPServer
+from plomrogue.commands import (cmd_ALL, cmd_LOGIN, cmd_QUERY, cmd_PING, cmd_MAP,
+                                cmd_TURN, cmd_MAP_LINE, cmd_GET_ANNOTATION,
+                                cmd_ANNOTATE, cmd_PORTAL, cmd_GET_GAMESTATE)
+from plomrogue.tasks import (Task_WAIT, Task_MOVE, Task_WRITE,
+                             Task_FLATTEN_SURROUNDINGS)
+import sys
+
+if len(sys.argv) != 2:
+    print('wrong number of arguments, expected one (save file)')
+    exit(1)
+savefile = sys.argv[1]
+game = Game(savefile)
+game.register_command(cmd_PING)
+game.register_command(cmd_LOGIN)
+game.register_command(cmd_QUERY)
+game.register_command(cmd_TURN)
+game.register_command(cmd_MAP)
+game.register_command(cmd_MAP_LINE)
+game.register_command(cmd_GET_ANNOTATION)
+game.register_command(cmd_ANNOTATE)
+game.register_command(cmd_PORTAL)
+game.register_command(cmd_GET_GAMESTATE)
+game.register_task(Task_WAIT)
+game.register_task(Task_MOVE)
+game.register_task(Task_WRITE)
+game.register_task(Task_FLATTEN_SURROUNDINGS)
+game.read_savefile()
+game.io.start_loop()
+game.io.start_server(8000, PlomWebSocketServer)
+game.io.start_server(5000, PlomTCPServer)
diff --git a/rogue_chat_curses.py b/rogue_chat_curses.py
new file mode 100755 (executable)
index 0000000..a6d1ca7
--- /dev/null
@@ -0,0 +1,613 @@
+#!/usr/bin/env python3
+import curses
+import queue
+import threading
+from plomrogue.game import GameBase
+from plomrogue.parser import Parser
+from plomrogue.mapping import YX, MapGeometrySquare, MapGeometryHex
+from plomrogue.things import ThingBase
+from plomrogue.misc import quote
+from plomrogue.errors import BrokenSocketConnection
+
+from ws4py.client import WebSocketBaseClient
+class WebSocketClient(WebSocketBaseClient):
+
+    def __init__(self, recv_handler, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self.recv_handler = recv_handler
+        self.connect()
+
+    def received_message(self, message):
+        if message.is_text:
+            message = str(message)
+            self.recv_handler(message)
+
+    @property
+    def plom_closed(self):
+        return self.client_terminated
+
+from plomrogue.io_tcp import PlomSocket
+class PlomSocketClient(PlomSocket):
+
+    def __init__(self, recv_handler, url):
+        import socket
+        self.recv_handler = recv_handler
+        host, port = url.split(':')
+        super().__init__(socket.create_connection((host, port)))
+
+    def close(self):
+        self.socket.close()
+
+    def run(self):
+        import ssl
+        try:
+            for msg in self.recv():
+                if msg == 'NEED_SSL':
+                    self.socket = ssl.wrap_socket(self.socket)
+                    continue
+                self.recv_handler(msg)
+        except BrokenSocketConnection:
+            pass  # we assume socket will be known as dead by now
+
+def cmd_TURN(game, n):
+    game.turn = n
+    game.things = []
+    game.portals = {}
+    game.turn_complete = False
+cmd_TURN.argtypes = 'int:nonneg'
+
+def cmd_LOGIN_OK(game):
+    game.tui.switch_mode('post_login_wait')
+    game.tui.send('GET_GAMESTATE')
+    game.tui.log_msg('@ welcome')
+cmd_LOGIN_OK.argtypes = ''
+
+def cmd_CHAT(game, msg):
+    game.tui.log_msg('# ' + msg)
+    game.tui.do_refresh = True
+cmd_CHAT.argtypes = 'string'
+
+def cmd_PLAYER_ID(game, player_id):
+    game.player_id = player_id
+cmd_PLAYER_ID.argtypes = 'int:nonneg'
+
+def cmd_THING_POS(game, thing_id, position):
+    t = game.get_thing(thing_id, True)
+    t.position = position
+cmd_THING_POS.argtypes = 'int:nonneg yx_tuple:nonneg'
+
+def cmd_THING_NAME(game, thing_id, name):
+    t = game.get_thing(thing_id, True)
+    t.name = name
+cmd_THING_NAME.argtypes = 'int:nonneg string'
+
+def cmd_MAP(game, geometry, size, content):
+    map_geometry_class = globals()['MapGeometry' + geometry]
+    game.map_geometry = map_geometry_class(size)
+    game.map_content = content
+    if type(game.map_geometry) == MapGeometrySquare:
+        game.tui.movement_keys = {
+            game.tui.keys['square_move_up']: 'UP',
+            game.tui.keys['square_move_left']: 'LEFT',
+            game.tui.keys['square_move_down']: 'DOWN',
+            game.tui.keys['square_move_right']: 'RIGHT',
+        }
+    elif type(game.map_geometry) == MapGeometryHex:
+        game.tui.movement_keys = {
+            game.tui.keys['hex_move_upleft']: 'UPLEFT',
+            game.tui.keys['hex_move_upright']: 'UPRIGHT',
+            game.tui.keys['hex_move_right']: 'RIGHT',
+            game.tui.keys['hex_move_downright']: 'DOWNRIGHT',
+            game.tui.keys['hex_move_downleft']: 'DOWNLEFT',
+            game.tui.keys['hex_move_left']: 'LEFT',
+        }
+cmd_MAP.argtypes = 'string:map_geometry yx_tuple:pos string'
+
+def cmd_GAME_STATE_COMPLETE(game):
+    game.info_db = {}
+    if game.tui.mode.name == 'post_login_wait':
+        game.tui.switch_mode('play')
+        game.tui.help()
+    if game.tui.mode.shows_info:
+        game.tui.query_info()
+    player = game.get_thing(game.player_id, False)
+    if player.position in game.portals:
+        game.tui.teleport_target_host = game.portals[player.position]
+        game.tui.switch_mode('teleport')
+    game.turn_complete = True
+    game.tui.do_refresh = True
+cmd_GAME_STATE_COMPLETE.argtypes = ''
+
+def cmd_PORTAL(game, position, msg):
+    game.portals[position] = msg
+cmd_PORTAL.argtypes = 'yx_tuple:nonneg string'
+
+def cmd_PLAY_ERROR(game, msg):
+    game.tui.flash()
+    game.tui.do_refresh = True
+cmd_PLAY_ERROR.argtypes = 'string'
+
+def cmd_GAME_ERROR(game, msg):
+    game.tui.log_msg('? game error: ' + msg)
+    game.tui.do_refresh = True
+cmd_GAME_ERROR.argtypes = 'string'
+
+def cmd_ARGUMENT_ERROR(game, msg):
+    game.tui.log_msg('? syntax error: ' + msg)
+    game.tui.do_refresh = True
+cmd_ARGUMENT_ERROR.argtypes = 'string'
+
+def cmd_ANNOTATION(game, position, msg):
+    game.info_db[position] = msg
+    if game.tui.mode.shows_info:
+        game.tui.do_refresh = True
+cmd_ANNOTATION.argtypes = 'yx_tuple:nonneg string'
+
+def cmd_PONG(game):
+    pass
+cmd_PONG.argtypes = ''
+
+class Game(GameBase):
+    thing_type = ThingBase
+    turn_complete = False
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self.register_command(cmd_LOGIN_OK)
+        self.register_command(cmd_PONG)
+        self.register_command(cmd_CHAT)
+        self.register_command(cmd_PLAYER_ID)
+        self.register_command(cmd_TURN)
+        self.register_command(cmd_THING_POS)
+        self.register_command(cmd_THING_NAME)
+        self.register_command(cmd_MAP)
+        self.register_command(cmd_PORTAL)
+        self.register_command(cmd_ANNOTATION)
+        self.register_command(cmd_GAME_STATE_COMPLETE)
+        self.register_command(cmd_ARGUMENT_ERROR)
+        self.register_command(cmd_GAME_ERROR)
+        self.register_command(cmd_PLAY_ERROR)
+        self.map_content = ''
+        self.player_id = -1
+        self.info_db = {}
+        self.portals = {}
+
+    def get_string_options(self, string_option_type):
+        if string_option_type == 'map_geometry':
+            return ['Hex', 'Square']
+        return None
+
+    def get_command(self, command_name):
+        from functools import partial
+        f = partial(self.commands[command_name], self)
+        f.argtypes = self.commands[command_name].argtypes
+        return f
+
+class TUI:
+
+    class Mode:
+
+        def __init__(self, name, has_input_prompt=False, shows_info=False,
+                     is_intro = False):
+            self.name = name
+            self.has_input_prompt = has_input_prompt
+            self.shows_info = shows_info
+            self.is_intro = is_intro
+
+    def __init__(self, host):
+        import os
+        import json
+        self.host = host
+        self.mode_play = self.Mode('play')
+        self.mode_study = self.Mode('study', shows_info=True)
+        self.mode_edit = self.Mode('edit')
+        self.mode_annotate = self.Mode('annotate', has_input_prompt=True, shows_info=True)
+        self.mode_portal = self.Mode('portal', has_input_prompt=True, shows_info=True)
+        self.mode_chat = self.Mode('chat', has_input_prompt=True)
+        self.mode_waiting_for_server = self.Mode('waiting_for_server', is_intro=True)
+        self.mode_login = self.Mode('login', has_input_prompt=True, is_intro=True)
+        self.mode_post_login_wait = self.Mode('post_login_wait', is_intro=True)
+        self.mode_teleport = self.Mode('teleport', has_input_prompt=True)
+        self.game = Game()
+        self.game.tui = self
+        self.parser = Parser(self.game)
+        self.log = []
+        self.do_refresh = True
+        self.queue = queue.Queue()
+        self.login_name = None
+        self.switch_mode('waiting_for_server')
+        self.keys = {
+            'switch_to_chat': 't',
+            'switch_to_play': 'p',
+            'switch_to_annotate': 'm',
+            'switch_to_portal': 'P',
+            'switch_to_study': '?',
+            'switch_to_edit': 'm',
+            'flatten': 'F',
+            'hex_move_upleft': 'w',
+            'hex_move_upright': 'e',
+            'hex_move_right': 'd',
+            'hex_move_downright': 'x',
+            'hex_move_downleft': 'y',
+            'hex_move_left': 'a',
+            'square_move_up': 'w',
+            'square_move_left': 'a',
+            'square_move_down': 's',
+            'square_move_right': 'd',
+        }
+        if os.path.isfile('config.json'):
+            with open('config.json', 'r') as f:
+                keys_conf = json.loads(f.read())
+            for k in keys_conf:
+                self.keys[k] = keys_conf[k]
+        curses.wrapper(self.loop)
+
+    def flash(self):
+        curses.flash()
+
+    def send(self, msg):
+        try:
+            if hasattr(self.socket, 'plom_closed') and self.socket.plom_closed:
+                raise BrokenSocketConnection
+            self.socket.send(msg)
+        except (BrokenPipeError, BrokenSocketConnection):
+            self.log_msg('@ server disconnected :(')
+            self.do_refresh = True
+
+    def log_msg(self, msg):
+        self.log += [msg]
+        if len(self.log) > 100:
+            self.log = self.log[-100:]
+
+    def query_info(self):
+        self.send('GET_ANNOTATION ' + str(self.explorer))
+
+    def switch_mode(self, mode_name, keep_position = False):
+        self.mode = getattr(self, 'mode_' + mode_name)
+        if self.mode.shows_info and not keep_position:
+            player = self.game.get_thing(self.game.player_id, False)
+            self.explorer = YX(player.position.y, player.position.x)
+        if self.mode.name == 'waiting_for_server':
+            self.log_msg('@ waiting for server …')
+        elif self.mode.name == 'login':
+            if self.login_name:
+                self.send('LOGIN ' + quote(self.login_name))
+            else:
+                self.log_msg('@ enter username')
+        elif self.mode.name == 'teleport':
+            self.log_msg("@ May teleport to %s" % (self.teleport_target_host)),
+            self.log_msg("@ Enter 'YES!' to enthusiastically affirm.");
+        elif self.mode.name == 'annotate' and self.explorer in self.game.info_db:
+            info = self.game.info_db[self.explorer]
+            if info != '(none)':
+                self.input_ = info
+        elif self.mode.name == 'portal' and self.explorer in self.game.portals:
+            self.input_ = self.game.portals[self.explorer]
+
+    def help(self):
+        self.log_msg("HELP:");
+        self.log_msg("chat mode commands:");
+        self.log_msg("  /nick NAME - re-name yourself to NAME");
+        self.log_msg("  /msg USER TEXT - send TEXT to USER");
+        self.log_msg("  /help - show this help");
+        self.log_msg("  /%s or /play - switch to play mode" % self.keys['switch_to_play']);
+        self.log_msg("  /%s or /study - switch to study mode" % self.keys['switch_to_study']);
+        self.log_msg("commands common to study and play mode:");
+        self.log_msg("  %s - move" % ','.join(self.movement_keys));
+        self.log_msg("  %s - switch to chat mode" % self.keys['switch_to_chat']);
+        self.log_msg("commands specific to play mode:");
+        self.log_msg("  %s - write following ASCII character" % self.keys['switch_to_edit']);
+        self.log_msg("  %s - flatten surroundings" % self.keys['flatten']);
+        self.log_msg("  %s - switch to study mode" % self.keys['switch_to_study']);
+        self.log_msg("commands specific to study mode:");
+        self.log_msg("  %s - annotate terrain" % self.keys['switch_to_annotate']);
+        self.log_msg("  %s - switch to play mode" % self.keys['switch_to_play']);
+
+    def loop(self, stdscr):
+        import time
+        import datetime
+
+        def safe_addstr(y, x, line):
+            if y < self.size.y - 1 or x + len(line) < self.size.x:
+                stdscr.addstr(y, x, line)
+            else:  # workaround to <https://stackoverflow.com/q/7063128>
+                cut_i = self.size.x - x - 1
+                cut = line[:cut_i]
+                last_char = line[cut_i]
+                stdscr.addstr(y, self.size.x - 2, last_char)
+                stdscr.insstr(y, self.size.x - 2, ' ')
+                stdscr.addstr(y, x, cut)
+
+        def connect():
+
+            def handle_recv(msg):
+                if msg == 'BYE':
+                    self.socket.close()
+                else:
+                    self.queue.put(msg)
+
+            socket_client_class = PlomSocketClient
+            if self.host.startswith('ws://') or self.host.startswith('wss://'):
+                socket_client_class = WebSocketClient
+            while True:
+                try:
+                    self.socket = socket_client_class(handle_recv, self.host)
+                    self.socket_thread = threading.Thread(target=self.socket.run)
+                    self.socket_thread.start()
+                    self.switch_mode('login')
+                    return
+                except ConnectionRefusedError:
+                    self.log_msg('@ server connect failure, trying again …')
+                    draw_screen()
+                    stdscr.refresh()
+                    time.sleep(1)
+
+        def reconnect():
+            self.send('QUIT')
+            time.sleep(0.1)  # FIXME necessitated by some some strange SSL race
+                             # conditions with ws4py, find out what exactly
+            self.switch_mode('waiting_for_server')
+            connect()
+
+        def handle_input(msg):
+            command, args = self.parser.parse(msg)
+            command(*args)
+
+        def msg_into_lines_of_width(msg, width):
+            chunk = ''
+            lines = []
+            x = 0
+            for i in range(len(msg)):
+                if x >= width or msg[i] == "\n":
+                    lines += [chunk]
+                    chunk = ''
+                    x = 0
+                if msg[i] != "\n":
+                    chunk += msg[i]
+                x += 1
+            lines += [chunk]
+            return lines
+
+        def reset_screen_size():
+            self.size = YX(*stdscr.getmaxyx())
+            self.size = self.size - YX(self.size.y % 4, 0)
+            self.size = self.size - YX(0, self.size.x % 4)
+            self.window_width = int(self.size.x / 2)
+
+        def recalc_input_lines():
+            if not self.mode.has_input_prompt:
+                self.input_lines = []
+            else:
+                self.input_lines = msg_into_lines_of_width(input_prompt + self.input_,
+                                                           self.window_width)
+
+        def move_explorer(direction):
+            target = self.game.map_geometry.move(self.explorer, direction)
+            if target:
+                self.explorer = target
+                self.query_info()
+            else:
+                self.flash()
+
+        def draw_history():
+            lines = []
+            for line in self.log:
+                lines += msg_into_lines_of_width(line, self.window_width)
+            lines.reverse()
+            height_header = 2
+            max_y = self.size.y - len(self.input_lines)
+            for i in range(len(lines)):
+                if (i >= max_y - height_header):
+                    break
+                safe_addstr(max_y - i - 1, self.window_width, lines[i])
+
+        def draw_info():
+            if not self.game.turn_complete:
+                return
+            pos_i = self.explorer.y * self.game.map_geometry.size.x + self.explorer.x
+            info = 'TERRAIN: %s\n' % self.game.map_content[pos_i]
+            for t in self.game.things:
+                if t.position == self.explorer:
+                    info += 'PLAYER @: %s\n' % t.name
+            if self.explorer in self.game.portals:
+                info += 'PORTAL: ' + self.game.portals[self.explorer] + '\n'
+            else:
+                info += 'PORTAL: (none)\n'
+            if self.explorer in self.game.info_db:
+                info += 'ANNOTATION: ' + self.game.info_db[self.explorer]
+            else:
+                info += 'ANNOTATION: waiting …'
+            lines = msg_into_lines_of_width(info, self.window_width)
+            height_header = 2
+            for i in range(len(lines)):
+                y = height_header + i
+                if y >= self.size.y - len(self.input_lines):
+                    break
+                safe_addstr(y, self.window_width, lines[i])
+
+        def draw_input():
+            y = self.size.y - len(self.input_lines)
+            for i in range(len(self.input_lines)):
+                safe_addstr(y, self.window_width, self.input_lines[i])
+                y += 1
+
+        def draw_turn():
+            if not self.game.turn_complete:
+                return
+            safe_addstr(0, self.window_width, 'TURN: ' + str(self.game.turn))
+
+        def draw_mode():
+            safe_addstr(1, self.window_width, 'MODE: ' + self.mode.name)
+
+        def draw_map():
+            if not self.game.turn_complete:
+                return
+            map_lines_split = []
+            for y in range(self.game.map_geometry.size.y):
+                start = self.game.map_geometry.size.x * y
+                end = start + self.game.map_geometry.size.x
+                map_lines_split += [list(self.game.map_content[start:end])]
+            for t in self.game.things:
+                map_lines_split[t.position.y][t.position.x] = '@'
+            if self.mode.shows_info:
+                map_lines_split[self.explorer.y][self.explorer.x] = '?'
+            map_lines = []
+            if type(self.game.map_geometry) == MapGeometryHex:
+                indent = 0
+                for line in map_lines_split:
+                    map_lines += [indent*' ' + ' '.join(line)]
+                    indent = 0 if indent else 1
+            else:
+                for line in map_lines_split:
+                    map_lines += [' '.join(line)]
+            window_center = YX(int(self.size.y / 2),
+                               int(self.window_width / 2))
+            player = self.game.get_thing(self.game.player_id, False)
+            center = player.position
+            if self.mode.shows_info:
+                center = self.explorer
+            center = YX(center.y, center.x * 2)
+            offset = center - window_center
+            if type(self.game.map_geometry) == MapGeometryHex and offset.y % 2:
+                offset += YX(0, 1)
+            term_y = max(0, -offset.y)
+            term_x = max(0, -offset.x)
+            map_y = max(0, offset.y)
+            map_x = max(0, offset.x)
+            while (term_y < self.size.y and map_y < self.game.map_geometry.size.y):
+                to_draw = map_lines[map_y][map_x:self.window_width + offset.x]
+                safe_addstr(term_y, term_x, to_draw)
+                term_y += 1
+                map_y += 1
+
+        def draw_screen():
+            stdscr.clear()
+            recalc_input_lines()
+            if self.mode.has_input_prompt:
+                draw_input()
+            if self.mode.shows_info:
+                draw_info()
+            else:
+                draw_history()
+            draw_mode()
+            if not self.mode.is_intro:
+                draw_turn()
+                draw_map()
+
+        curses.curs_set(False)  # hide cursor
+        curses.use_default_colors();
+        stdscr.timeout(10)
+        reset_screen_size()
+        self.explorer = YX(0, 0)
+        self.input_ = ''
+        input_prompt = '> '
+        connect()
+        last_ping = datetime.datetime.now()
+        interval = datetime.timedelta(seconds=30)
+        while True:
+            now = datetime.datetime.now()
+            if now - last_ping > interval:
+                self.send('PING')
+                last_ping = now
+            if self.do_refresh:
+                draw_screen()
+                self.do_refresh = False
+            while True:
+                try:
+                    msg = self.queue.get(block=False)
+                    handle_input(msg)
+                except queue.Empty:
+                    break
+            try:
+                key = stdscr.getkey()
+                self.do_refresh = True
+            except curses.error:
+                continue
+            if key == 'KEY_RESIZE':
+                reset_screen_size()
+            elif self.mode.has_input_prompt and key == 'KEY_BACKSPACE':
+                self.input_ = self.input_[:-1]
+            elif self.mode.has_input_prompt and key != '\n':  # Return key
+                self.input_ += key
+                max_length = self.window_width * self.size.y - len(input_prompt) - 1
+                if len(self.input_) > max_length:
+                    self.input_ = self.input_[:max_length]
+            elif self.mode == self.mode_login and key == '\n':
+                self.login_name = self.input_
+                self.send('LOGIN ' + quote(self.input_))
+                self.input_ = ""
+            elif self.mode == self.mode_chat and key == '\n':
+                if self.input_[0] == '/':
+                    if self.input_ in {'/' + self.keys['switch_to_play'], '/play'}:
+                        self.switch_mode('play')
+                    elif self.input_ in {'/' + self.keys['switch_to_study'], '/study'}:
+                        self.switch_mode('study')
+                    elif self.input_ == '/help':
+                        self.help()
+                    elif self.input_ == '/reconnect':
+                        reconnect()
+                    elif self.input_.startswith('/nick'):
+                        tokens = self.input_.split(maxsplit=1)
+                        if len(tokens) == 2:
+                            self.send('LOGIN ' + quote(tokens[1]))
+                        else:
+                            self.log_msg('? need login name')
+                    elif self.input_.startswith('/msg'):
+                        tokens = self.input_.split(maxsplit=2)
+                        if len(tokens) == 3:
+                            self.send('QUERY %s %s' % (quote(tokens[1]),
+                                                              quote(tokens[2])))
+                        else:
+                            self.log_msg('? need message target and message')
+                    else:
+                        self.log_msg('? unknown command')
+                else:
+                    self.send('ALL ' + quote(self.input_))
+                self.input_ = ""
+            elif self.mode == self.mode_annotate and key == '\n':
+                if self.input_ == '':
+                    self.input_ = ' '
+                self.send('ANNOTATE %s %s' % (self.explorer, quote(self.input_)))
+                self.input_ = ""
+                self.switch_mode('study', keep_position=True)
+            elif self.mode == self.mode_portal and key == '\n':
+                if self.input_ == '':
+                    self.input_ = ' '
+                self.send('PORTAL %s %s' % (self.explorer, quote(self.input_)))
+                self.input_ = ""
+                self.switch_mode('study', keep_position=True)
+            elif self.mode == self.mode_teleport and key == '\n':
+                if self.input_ == 'YES!':
+                    self.host = self.teleport_target_host
+                    reconnect()
+                else:
+                    self.log_msg('@ teleport aborted')
+                    self.switch_mode('play')
+                self.input_ = ''
+            elif self.mode == self.mode_study:
+                if key == self.keys['switch_to_chat']:
+                    self.switch_mode('chat')
+                elif key == self.keys['switch_to_play']:
+                    self.switch_mode('play')
+                elif key == self.keys['switch_to_annotate']:
+                    self.switch_mode('annotate', keep_position=True)
+                elif key == self.keys['switch_to_portal']:
+                    self.switch_mode('portal', keep_position=True)
+                elif key in self.movement_keys:
+                    move_explorer(self.movement_keys[key])
+            elif self.mode == self.mode_play:
+                if key == self.keys['switch_to_chat']:
+                    self.switch_mode('chat')
+                elif key == self.keys['switch_to_study']:
+                    self.switch_mode('study')
+                if key == self.keys['switch_to_edit']:
+                    self.switch_mode('edit')
+                elif key == self.keys['flatten']:
+                    self.send('TASK:FLATTEN_SURROUNDINGS')
+                elif key in self.movement_keys:
+                    self.send('TASK:MOVE ' + self.movement_keys[key])
+            elif self.mode == self.mode_edit:
+                self.send('TASK:WRITE ' + key)
+                self.switch_mode('play')
+
+TUI('localhost:5000')
diff --git a/rogue_chat_nocanvas_monochrome.html b/rogue_chat_nocanvas_monochrome.html
new file mode 100644 (file)
index 0000000..ab3ce34
--- /dev/null
@@ -0,0 +1,809 @@
+<!DOCTYPE html>
+<html><head>
+<style>
+</style>
+</head><body>
+<div>
+terminal rows: <input id="n_rows" type="number" step=4 min=8 value=24 />
+terminal columns: <input id="n_cols" type="number" step=4 min=20 value=80 />
+</div>
+<pre id="terminal" style="display: inline-block;"></pre>
+<textarea id="input" style="opacity: 0; width: 0px;"></textarea>
+<div>
+keys (see <a href="https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key/Key_Values">here</a> for non-obvious available values):<br />
+move up (square grid): <input id="key_square_move_up" type="text" value="w" /> (hint: ArrowUp)<br />
+move left (square grid): <input id="key_square_move_left" type="text" value="a" /> (hint: ArrowLeft)<br />
+move down (square grid): <input id="key_square_move_down" type="text" value="s" /> (hint: ArrowDown)<br />
+move right (square grid): <input id="key_square_move_right" type="text" value="d" /> (hint: ArrowRight)<br />
+move up-left (hex grid): <input id="key_hex_move_upleft" type="text" value="w" /><br />
+move up-right (hex grid): <input id="key_hex_move_upright" type="text" value="e" /><br />
+move right (hex grid): <input id="key_hex_move_right" type="text" value="d" /><br />
+move down-right (hex grid): <input id="key_hex_move_downright" type="text" value="x" /><br />
+move down-left (hex grid): <input id="key_hex_move_downleft" type="text" value="y" /><br />
+move left (hex grid): <input id="key_hex_move_left" type="text" value="a" /><br />
+flatten surroundings: <input id="key_flatten" type="text" value="F" /><br />
+switch to chat mode: <input id="key_switch_to_chat" type="text" value="t" /><br />
+switch to play mode: <input id="key_switch_to_play" type="text" value="p" /><br />
+switch to study mode: <input id="key_switch_to_study" type="text" value="?" /><br />
+edit terrain (from play mode): <input id="key_switch_to_edit" type="text" value="m" /><br />
+annotate terrain (from study mode): <input id="key_switch_to_annotate" type="text" value="m" /><br />
+annotate portal (from study mode): <input id="key_switch_to_portal" type="text" value="P" /><br />
+</div>
+<script>
+"use strict";
+let websocket_location = "ws://localhost:8000";
+
+let rows_selector = document.getElementById("n_rows");
+let cols_selector = document.getElementById("n_cols");
+let key_selectors = document.querySelectorAll('[id^="key_"]');
+
+function restore_selector_value(selector) {
+    let stored_selection = window.localStorage.getItem(selector.id);
+    if (stored_selection) {
+        selector.value = stored_selection;
+    }
+}
+restore_selector_value(rows_selector);
+restore_selector_value(cols_selector);
+for (let key_selector of key_selectors) {
+    restore_selector_value(key_selector);
+}
+
+let terminal = {
+  foreground: 'white',
+  background: 'black',
+  initialize: function() {
+    this.rows = rows_selector.value;
+    this.cols = cols_selector.value;
+    this.pre_el = document.getElementById("terminal");
+    this.pre_el.style.color = this.foreground;
+    this.pre_el.style.backgroundColor = this.background;
+    this.content = [];
+      let line = []
+    for (let y = 0, x = 0; y <= this.rows; x++) {
+        if (x == this.cols) {
+            x = 0;
+            y += 1;
+            this.content.push(line);
+            line = [];
+            if (y == this.rows) {
+                break;
+            }
+        }
+        line.push(' ');
+    }
+  },
+  blink_screen: function() {
+      this.pre_el.style.color = this.background;
+      this.pre_el.style.backgroundColor = this.foreground;
+      setTimeout(() => {
+          this.pre_el.style.color = this.foreground;
+          this.pre_el.style.backgroundColor = this.background;
+      }, 100);
+  },
+  refresh: function() {
+      let pre_string = '';
+      for (let y = 0; y < this.rows; y++) {
+          let line = this.content[y].join('');
+          pre_string += line + '\n';
+      }
+      this.pre_el.textContent = pre_string;
+  },
+  write: function(start_y, start_x, msg) {
+      for (let x = start_x, i = 0; x < this.cols && i < msg.length; x++, i++) {
+          this.content[start_y][x] = msg[i];
+      }
+  },
+  drawBox: function(start_y, start_x, height, width) {
+    let end_y = start_y + height;
+    let end_x = start_x + width;
+    for (let y = start_y, x = start_x; y < this.rows; x++) {
+      if (x == end_x) {
+        x = start_x;
+        y += 1;
+        if (y == end_y) {
+            break;
+        }
+      };
+      this.content[y][x] = ' ';
+    }
+  },
+}
+terminal.initialize();
+
+let parser = {
+  tokenize: function(str) {
+    let token_ends = [];
+    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) {
+          token_ends.push(i);
+          tokens.push(token);
+          token = '';
+        }
+      } else {
+        token += c;
+      }
+    }
+    if (token.length > 0) {
+      tokens.push(token);
+    }
+    let token_starts = [];
+    for (let i = 0; i < token_ends.length; i++) {
+      token_starts.push(token_ends[i] - tokens[i].length);
+    };
+    return [tokens, token_starts];
+  },
+  parse_yx: function(position_string) {
+    let coordinate_strings = position_string.split(',')
+    let position = [0, 0];
+    position[0] = parseInt(coordinate_strings[0].slice(2));
+    position[1] = parseInt(coordinate_strings[1].slice(2));
+    return position;
+  },
+}
+
+class Thing {
+    constructor(yx) {
+        this.position = yx;
+    }
+}
+
+let server = {
+    init: function(url) {
+        this.url = url;
+        this.websocket = new WebSocket(this.url);
+        this.websocket.onopen = function(event) {
+            window.setInterval(function() { server.send(['PING']) }, 30000);
+            tui.log_msg("@ server connected! :)");
+            tui.switch_mode(mode_login);
+        };
+        this.websocket.onclose = function(event) {
+            tui.log_msg("@ server disconnected :(");
+            tui.log_msg("@ hint: try the '/reconnect' command");
+        };
+           this.websocket.onmessage = this.handle_event;
+        },
+    reconnect: function() {
+          this.reconnect_to(this.url);
+    },
+    reconnect_to: function(url) {
+        this.websocket.close();
+        this.init(url);
+    },
+    send: function(tokens) {
+        this.websocket.send(unparser.untokenize(tokens));
+    },
+    handle_event: function(event) {
+        let tokens = parser.tokenize(event.data)[0];
+        if (tokens[0] === 'TURN') {
+            game.turn_complete = false;
+            game.things = {};
+            game.portals = {};
+            game.turn = parseInt(tokens[1]);
+        } else if (tokens[0] === 'THING_POS') {
+            game.get_thing(tokens[1], true).position = parser.parse_yx(tokens[2]);
+        } else if (tokens[0] === 'THING_NAME') {
+            game.get_thing(tokens[1], true).name_ = tokens[2];
+        } else if (tokens[0] === 'MAP') {
+            game.map_geometry = tokens[1];
+            tui.init_keys();
+            game.map_size = parser.parse_yx(tokens[2]);
+            game.map = tokens[3]
+        } else if (tokens[0] === 'GAME_STATE_COMPLETE') {
+            game.turn_complete = true;
+            explorer.empty_info_db();
+            if (tui.mode == mode_post_login_wait) {
+                tui.switch_mode(mode_play);
+                tui.log_help();
+            } else if (tui.mode == mode_study) {
+                explorer.query_info();
+            }
+            let t = game.get_thing(game.player_id);
+            if (t.position in game.portals) {
+                tui.teleport_target = game.portals[t.position];
+                tui.switch_mode(mode_teleport);
+                return;
+            }
+            tui.full_refresh();
+        } else if (tokens[0] === 'CHAT') {
+             tui.log_msg('# ' + tokens[1], 1);
+        } else if (tokens[0] === 'PLAYER_ID') {
+            game.player_id = parseInt(tokens[1]);
+        } else if (tokens[0] === 'LOGIN_OK') {
+            this.send(['GET_GAMESTATE']);
+            tui.switch_mode(mode_post_login_wait);
+        } else if (tokens[0] === 'PORTAL') {
+            let position = parser.parse_yx(tokens[1]);
+            game.portals[position] = tokens[2];
+        } else if (tokens[0] === 'ANNOTATION') {
+            let position = parser.parse_yx(tokens[1]);
+            explorer.update_info_db(position, tokens[2]);
+        } else if (tokens[0] === 'UNHANDLED_INPUT') {
+            tui.log_msg('? unknown command');
+        } else if (tokens[0] === 'PLAY_ERROR') {
+            terminal.blink_screen();
+        } 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 if (tokens[0] === 'PONG') {
+            console.log('PONG');
+        } else {
+            tui.log_msg('? unhandled input: ' + event.data);
+        }
+    }
+}
+
+let unparser = {
+    quote: function(str) {
+        let quoted = ['"'];
+        for (let i = 0; i < str.length; i++) {
+            let c = str[i];
+            if (['"', '\\'].includes(c)) {
+                quoted.push('\\');
+            };
+            quoted.push(c);
+        }
+        quoted.push('"');
+        return quoted.join('');
+    },
+    to_yx: function(yx_coordinate) {
+        return "Y:" + yx_coordinate[0] + ",X:" + yx_coordinate[1];
+    },
+    untokenize: function(tokens) {
+        let quoted_tokens = [];
+        for (let token of tokens) {
+            quoted_tokens.push(this.quote(token));
+        }
+        return quoted_tokens.join(" ");
+    }
+}
+
+class Mode {
+    constructor(name, has_input_prompt=false, shows_info=false, is_intro=false) {
+        this.name = name;
+        this.has_input_prompt = has_input_prompt;
+        this.shows_info= shows_info;
+        this.is_intro = is_intro;
+    }
+}
+let mode_waiting_for_server = new Mode('waiting_for_server', false, false, true);
+let mode_login = new Mode('login', true, false, true);
+let mode_post_login_wait = new Mode('waiting for game world', false, false, true);
+let mode_chat = new Mode('chat / write messages to players', true, false);
+let mode_annotate = new Mode('add message to map tile', true, true);
+let mode_play = new Mode('play / move around', false, false);
+let mode_study = new Mode('check map tiles for messages', false, true);
+let mode_edit = new Mode('write ASCII char to map tile', false, false);
+let mode_teleport = new Mode('teleport away?', true);
+let mode_portal = new Mode('add portal to map tile', true, true);
+
+let tui = {
+  mode: mode_waiting_for_server,
+  log: [],
+  input_prompt: '> ',
+  input_lines: [],
+  window_width: terminal.cols / 2,
+  height_turn_line: 1,
+  height_mode_line: 1,
+  height_input: 1,
+  init: function() {
+      this.inputEl = document.getElementById("input");
+      this.inputEl.focus();
+      this.recalc_input_lines();
+      this.height_header = this.height_turn_line + this.height_mode_line;
+      this.log_msg("@ waiting for server connection ...");
+      this.init_keys();
+  },
+  init_keys: function() {
+    this.keys = {};
+    for (let key_selector of key_selectors) {
+        this.keys[key_selector.id.slice(4)] = key_selector.value;
+    }
+    if (game.map_geometry == 'Square') {
+        this.movement_keys = {
+            [this.keys.square_move_up]: 'UP',
+            [this.keys.square_move_left]: 'LEFT',
+            [this.keys.square_move_down]: 'DOWN',
+            [this.keys.square_move_right]: 'RIGHT'
+        };
+    } else if (game.map_geometry == 'Hex') {
+        this.movement_keys = {
+            [this.keys.hex_move_upleft]: 'UPLEFT',
+            [this.keys.hex_move_upright]: 'UPRIGHT',
+            [this.keys.hex_move_right]: 'RIGHT',
+            [this.keys.hex_move_downright]: 'DOWNRIGHT',
+            [this.keys.hex_move_downleft]: 'DOWNLEFT',
+            [this.keys.hex_move_left]: 'LEFT'
+        };
+    };
+  },
+  switch_mode: function(mode, keep_pos=false) {
+    if (mode == mode_study && !keep_pos && game.player_id in game.things) {
+      explorer.position = game.things[game.player_id].position;
+    }
+    this.mode = mode;
+    this.empty_input();
+    if (mode == mode_annotate && explorer.position in explorer.info_db) {
+        let info = explorer.info_db[explorer.position];
+        if (info != "(none)") {
+           this.inputEl.value = info;
+           this.recalc_input_lines();
+        }
+    }
+    if (mode == mode_login) {
+        if (this.login_name) {
+            server.send(['LOGIN', this.login_name]);
+        } else {
+            this.log_msg("? need login name");
+        }
+    } else if (mode == mode_portal && explorer.position in game.portals) {
+        let portal = game.portals[explorer.position]
+        this.inputEl.value = portal;
+        this.recalc_input_lines();
+    } else if (mode == mode_teleport) {
+        tui.log_msg("@ May teleport to: " + tui.teleport_target);
+        tui.log_msg("@ Enter 'YES!' to entusiastically affirm.");
+    }
+    this.full_refresh();
+  },
+  empty_input: function(str) {
+      this.inputEl.value = "";
+      if (this.mode.has_input_prompt) {
+          this.recalc_input_lines();
+      } else {
+          this.height_input = 0;
+      }
+  },
+  recalc_input_lines: function() {
+      this.input_lines = this.msg_into_lines_of_width(this.input_prompt + this.inputEl.value, this.window_width);
+      this.height_input = this.input_lines.length;
+  },
+  msg_into_lines_of_width: function(msg, width) {
+    let chunk = "";
+    let lines = [];
+    for (let i = 0, x = 0; i < msg.length; i++, x++) {
+      if (x >= width || msg[i] == "\n") {
+        lines.push(chunk);
+        chunk = "";
+        x = 0;
+      };
+      if (msg[i] != "\n") {
+        chunk += msg[i];
+      }
+    }
+    lines.push(chunk);
+    return lines;
+  },
+  log_msg: function(msg) {
+      this.log.push(msg);
+      while (this.log.length > 100) {
+        this.log.shift();
+      };
+      this.full_refresh();
+  },
+  log_help: function() {
+    let movement_keys_desc = Object.keys(this.movement_keys).join(',');
+    this.log_msg("HELP:");
+    this.log_msg("chat mode commands:");
+    this.log_msg("  /nick NAME - re-name yourself to NAME");
+    this.log_msg("  /msg USER TEXT - send TEXT to USER");
+    this.log_msg("  /help - show this help");
+    this.log_msg("  /" + this.keys.switch_to_play + " or /play - switch to play mode");
+    this.log_msg("  /" + this.keys.switch_to_study + " or /study - switch to study mode");
+    this.log_msg("commands common to study and play mode:");
+    this.log_msg("  " + movement_keys_desc + " - move");
+    this.log_msg("  " + this.keys.switch_to_chat + " - switch to chat mode");
+    this.log_msg("commands specific to play mode:");
+    this.log_msg("  " + this.keys.switch_to_edit + " - write following ASCII character");
+    this.log_msg("  " + this.keys.flatten + " - flatten surroundings");
+    this.log_msg("  " + this.keys.switch_to_study + " - switch to study mode");
+    this.log_msg("commands specific to study mode:");
+    this.log_msg("  " + this.keys.switch_to_annotate + " - annotate terrain");
+    this.log_msg("  " + this.keys.switch_to_play + " - switch to play mode");
+  },
+  draw_map: function() {
+    let map_lines_split = [];
+    let line = [];
+    for (let i = 0, j = 0; i < game.map.length; i++, j++) {
+        if (j == game.map_size[1]) {
+            map_lines_split.push(line);
+            line = [];
+            j = 0;
+        };
+        line.push(game.map[i]);
+    };
+    map_lines_split.push(line);
+    for (const thing_id in game.things) {
+        let t = game.things[thing_id];
+        map_lines_split[t.position[0]][t.position[1]] = '@';
+    };
+    if (tui.mode.shows_info) {
+        map_lines_split[explorer.position[0]][explorer.position[1]] = '?';
+    }
+    let map_lines = []
+    if (game.map_geometry == 'Square') {
+        for (let line_split of map_lines_split) {
+            map_lines.push(line_split.join(' '));
+        };
+    } else if (game.map_geometry == 'Hex') {
+        let indent = 0
+        for (let line_split of map_lines_split) {
+            map_lines.push(' '.repeat(indent) + line_split.join(' '));
+            if (indent == 0) {
+                indent = 1;
+            } else {
+                indent = 0;
+            };
+        };
+    }
+    let window_center = [terminal.rows / 2, this.window_width / 2];
+    let player = game.things[game.player_id];
+    let center_position = [player.position[0], player.position[1]];
+    if (tui.mode.shows_info) {
+        center_position = [explorer.position[0], explorer.position[1]];
+    }
+    center_position[1] = center_position[1] * 2;
+    let offset = [center_position[0] - window_center[0],
+                  center_position[1] - window_center[1]]
+    if (game.map_geometry == 'Hex' && offset[0] % 2) {
+        offset[1] += 1;
+    };
+    let term_y = Math.max(0, -offset[0]);
+    let term_x = Math.max(0, -offset[1]);
+    let map_y = Math.max(0, offset[0]);
+    let map_x = Math.max(0, offset[1]);
+    for (; term_y < terminal.rows && map_y < game.map_size[0]; term_y++, map_y++) {
+        let to_draw = map_lines[map_y].slice(map_x, this.window_width + offset[1]);
+        terminal.write(term_y, term_x, to_draw);
+    }
+  },
+  draw_mode_line: function() {
+      terminal.write(0, this.window_width, 'MODE: ' + this.mode.name);
+  },
+  draw_turn_line: function(n) {
+    terminal.write(1, this.window_width, 'TURN: ' + game.turn);
+  },
+  draw_history: function() {
+      let log_display_lines = [];
+      for (let line of this.log) {
+          log_display_lines = log_display_lines.concat(this.msg_into_lines_of_width(line, this.window_width));
+      };
+      for (let y = terminal.rows - 1 - this.height_input,
+               i = log_display_lines.length - 1;
+           y >= this.height_header && i >= 0;
+           y--, i--) {
+          terminal.write(y, this.window_width, log_display_lines[i]);
+      }
+  },
+  draw_info: function() {
+    let lines = this.msg_into_lines_of_width(explorer.get_info(), this.window_width);
+    for (let y = this.height_header, i = 0; y < terminal.rows && i < lines.length; y++, i++) {
+      terminal.write(y, this.window_width, lines[i]);
+    }
+  },
+  draw_input: function() {
+    if (this.mode.has_input_prompt) {
+        for (let y = terminal.rows - this.height_input, i = 0; i < this.input_lines.length; y++, i++) {
+            terminal.write(y, this.window_width, this.input_lines[i]);
+        }
+    }
+  },
+  full_refresh: function() {
+    terminal.drawBox(0, 0, terminal.rows, terminal.cols);
+    if (this.mode.is_intro) {
+        this.draw_history();
+        this.draw_input();
+    } else {
+        if (game.turn_complete) {
+            this.draw_map();
+            this.draw_turn_line();
+        }
+        this.draw_mode_line();
+        if (this.mode.shows_info) {
+          this.draw_info();
+        } else {
+          this.draw_history();
+        }
+        this.draw_input();
+    }
+    terminal.refresh();
+  }
+}
+
+let game = {
+    init: function() {
+        this.things = {};
+        this.turn = -1;
+        this.map = "";
+        this.map_size = [0,0];
+        this.player_id = -1;
+        this.portals = {};
+    },
+    get_thing: function(id_, create_if_not_found=false) {
+        if (id_ in game.things) {
+            return game.things[id_];
+        } else if (create_if_not_found) {
+            let t = new Thing([0,0]);
+            game.things[id_] = t;
+            return t;
+        };
+    },
+    move: function(start_position, direction) {
+        let target = [start_position[0], start_position[1]];
+        if (direction == 'LEFT') {
+            target[1] -= 1;
+        } else if (direction == 'RIGHT') {
+            target[1] += 1;
+        } else if (game.map_geometry == 'Square') {
+            if (direction == 'UP') {
+                target[0] -= 1;
+            } else if (direction == 'DOWN') {
+                target[0] += 1;
+            };
+        } else if (game.map_geometry == 'Hex') {
+            let start_indented = start_position[0] % 2;
+            if (direction == 'UPLEFT') {
+                target[0] -= 1;
+                if (!start_indented) {
+                    target[1] -= 1;
+                }
+            } else if (direction == 'UPRIGHT') {
+                target[0] -= 1;
+                if (start_indented) {
+                    target[1] += 1;
+                }
+            } else if (direction == 'DOWNLEFT') {
+                target[0] += 1;
+                if (!start_indented) {
+                    target[1] -= 1;
+                }
+            } else if (direction == 'DOWNRIGHT') {
+                target[0] += 1;
+                if (start_indented) {
+                    target[1] += 1;
+                }
+            };
+        };
+        if (target[0] < 0 || target[1] < 0 ||
+            target[0] >= this.map_size[0] || target[1] >= this.map_size[1]) {
+            return null;
+        };
+        return target;
+    }
+}
+
+game.init();
+tui.init();
+tui.full_refresh();
+server.init(websocket_location);
+
+let explorer = {
+    position: [0,0],
+    info_db: {},
+    move: function(direction) {
+        let target = game.move(this.position, direction);
+        if (target) {
+            this.position = target
+            this.query_info();
+            tui.full_refresh();
+        } else {
+            terminal.blink_screen();
+        };
+    },
+    update_info_db: function(yx, str) {
+        this.info_db[yx] = str;
+        if (tui.mode == mode_study) {
+            tui.full_refresh();
+        }
+    },
+    empty_info_db: function() {
+        this.info_db = {};
+        if (tui.mode == mode_study) {
+            tui.full_refresh();
+        }
+    },
+    query_info: function() {
+        server.send(["GET_ANNOTATION", unparser.to_yx(explorer.position)]);
+    },
+    get_info: function() {
+        let info = "";
+        let position_i = this.position[0] * game.map_size[1] + this.position[1];
+        info += "TERRAIN: " + game.map[position_i] + "\n";
+        for (let t_id in game.things) {
+             let t = game.things[t_id];
+             if (t.position[0] == this.position[0] && t.position[1] == this.position[1]) {
+                 info += "PLAYER @";
+                 if (t.name_) {
+                     info += ": " + t.name_;
+                 }
+                 info += "\n";
+             }
+        }
+        if (this.position in game.portals) {
+            info += "PORTAL: " + game.portals[this.position] + "\n";
+        }
+        if (this.position in this.info_db) {
+            info += "ANNOTATIONS: " + this.info_db[this.position];
+        } else {
+            info += 'waiting …';
+        }
+        return info;
+    },
+    annotate: function(msg) {
+        if (msg.length == 0) {
+            msg = " ";  // triggers annotation deletion
+        }
+        server.send(["ANNOTATE", unparser.to_yx(explorer.position), msg]);
+    },
+    set_portal: function(msg) {
+        if (msg.length == 0) {
+            msg = " ";  // triggers portal deletion
+        }
+        server.send(["PORTAL", unparser.to_yx(explorer.position), msg]);
+    }
+}
+
+tui.inputEl.addEventListener('input', (event) => {
+    if (tui.mode.has_input_prompt) {
+        let max_length = tui.window_width * terminal.rows - tui.input_prompt.length;
+        if (tui.inputEl.value.length > max_length) {
+            tui.inputEl.value = tui.inputEl.value.slice(0, max_length);
+        };
+        tui.recalc_input_lines();
+        tui.full_refresh();
+    } else if (tui.mode == mode_edit && tui.inputEl.value.length > 0) {
+        server.send(["TASK:WRITE", tui.inputEl.value[0]]);
+        tui.switch_mode(mode_play);
+    } else if (tui.mode == mode_teleport) {
+        if (['Y', 'y'].includes(tui.inputEl.value[0])) {
+            server.reconnect_to(tui.teleport_target);
+       } else {
+            tui.log_msg("@ teleportation aborted");
+            tui.switch_mode(mode_play);
+       }
+    }
+}, false);
+tui.inputEl.addEventListener('keydown', (event) => {
+    if (event.key == 'Enter') {
+        event.preventDefault();
+    }
+    if (tui.mode == mode_login && event.key == 'Enter') {
+        tui.login_name = tui.inputEl.value;
+        server.send(['LOGIN', tui.inputEl.value]);
+        tui.empty_input();
+    } else if (tui.mode == mode_portal && event.key == 'Enter') {
+        explorer.set_portal(tui.inputEl.value);
+        tui.switch_mode(mode_study, true);
+    } else if (tui.mode == mode_annotate && event.key == 'Enter') {
+        explorer.annotate(tui.inputEl.value);
+        tui.switch_mode(mode_study, true);
+    } else if (tui.mode == mode_teleport && event.key == 'Enter') {
+        if (tui.inputEl.value == 'YES!') {
+            server.reconnect_to(tui.teleport_target);
+        } else {
+            tui.log_msg('@ teleport aborted');
+            tui.switch_mode(mode_play);
+        };
+    } else if (tui.mode == mode_chat && event.key == 'Enter') {
+        let [tokens, token_starts] = parser.tokenize(tui.inputEl.value);
+        if (tokens.length > 0 && tokens[0].length > 0) {
+            if (tui.inputEl.value[0][0] == '/') {
+                if (tokens[0].slice(1) == 'play' || tokens[0][1] == tui.keys.switch_to_play) {
+                    tui.switch_mode(mode_play);
+                } else if (tokens[0].slice(1) == 'study' || tokens[0][1] == tui.keys.switch_to_study) {
+                    tui.switch_mode(mode_study);
+                } else if (tokens[0].slice(1) == 'help') {
+                    tui.log_help();
+                } else if (tokens[0].slice(1) == 'nick') {
+                    if (tokens.length > 1) {
+                        server.send(['LOGIN', tokens[1]]);
+                    } else {
+                        tui.log_msg('? need login name');
+                    }
+                } else if (tokens[0].slice(1) == 'msg') {
+                    if (tokens.length > 2) {
+                        let msg = tui.inputEl.value.slice(token_starts[2]);
+                        server.send(['QUERY', tokens[1], msg]);
+                    } else {
+                        tui.log_msg('? need message target and message');
+                    }
+                } else if (tokens[0].slice(1) == 'reconnect') {
+                   if (tokens.length > 1) {
+                        server.reconnect_to(tokens[1]);
+                   } else {
+                        server.reconnect();
+                   }
+                } else {
+                    tui.log_msg('? unknown command');
+                }
+           } else {
+               server.send(['ALL', tui.inputEl.value]);
+            }
+       } else if (tui.inputEl.valuelength > 0) {
+           server.send(['ALL', tui.inputEl.value]);
+        }
+        tui.empty_input();
+        tui.full_refresh();
+      } else if (tui.mode == mode_play) {
+          if (event.key === tui.keys.switch_to_chat) {
+              event.preventDefault();
+              tui.switch_mode(mode_chat);
+          } else if (event.key === tui.keys.switch_to_edit) {
+              event.preventDefault();
+              tui.switch_mode(mode_edit);
+          } else if (event.key === tui.keys.switch_to_study) {
+              tui.switch_mode(mode_study);
+          } else if (event.key === tui.keys.flatten) {
+              server.send(["TASK:FLATTEN_SURROUNDINGS"]);
+          } else if (event.key in tui.movement_keys) {
+              server.send(['TASK:MOVE', tui.movement_keys[event.key]]);
+          };
+    } else if (tui.mode == mode_study) {
+        if (event.key === tui.keys.switch_to_chat) {
+            event.preventDefault();
+            tui.switch_mode(mode_chat);
+        } else if (event.key == tui.keys.switch_to_play) {
+            tui.switch_mode(mode_play);
+        } else if (event.key === tui.keys.switch_to_portal) {
+            event.preventDefault();
+            tui.switch_mode(mode_portal);
+        } else if (event.key in tui.movement_keys) {
+            explorer.move(tui.movement_keys[event.key]);
+        } else if (event.key === tui.keys.switch_to_annotate) {
+          event.preventDefault();
+          tui.switch_mode(mode_annotate);
+        };
+    }
+}, false);
+
+rows_selector.addEventListener('input', function() {
+    if (rows_selector.value % 4 != 0) {
+        return;
+    }
+    window.localStorage.setItem(rows_selector.id, rows_selector.value);
+    terminal.initialize();
+    tui.full_refresh();
+}, false);
+cols_selector.addEventListener('input', function() {
+    if (cols_selector.value % 4 != 0) {
+        return;
+    }
+    window.localStorage.setItem(cols_selector.id, cols_selector.value);
+    terminal.initialize();
+    tui.window_width = terminal.cols / 2,
+    tui.full_refresh();
+}, false);
+for (let key_selector of key_selectors) {
+    key_selector.addEventListener('input', function() {
+        window.localStorage.setItem(key_selector.id, key_selector.value);
+        tui.init_keys();
+    }, false);
+}
+window.setInterval(function() {
+    if (!(['input', 'n_cols', 'n_rows'].includes(document.activeElement.id)
+          || document.activeElement.id.startsWith('key_'))) {
+        tui.inputEl.focus();
+    }
+}, 100);
+</script>
+</body></html>