home · contact · privacy
Refactor parser code.
[plomrogue2] / rogue_chat_curses.py
index fd28049072a3fc62e9a310872ef242b935f91e68..4f6981df553696fb7d865f76453463a819e580dd 100755 (executable)
@@ -1,15 +1,16 @@
 #!/usr/bin/env python3
 import curses
 #!/usr/bin/env python3
 import curses
-import queue
-import threading
-import time
 import sys
 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
 import sys
 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, ArgError
+from plomrogue.errors import ArgError
+from plomrogue_client.socket import ClientSocket
+from plomrogue_client.tui import msg_into_lines_of_width, TUI
+
+
 
 mode_helps = {
     'play': {
 
 mode_helps = {
     'play': {
@@ -133,46 +134,6 @@ mode_helps = {
     }
 }
 
     }
 }
 
-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_complete = False
 cmd_TURN.argtypes = 'int:nonneg'
 def cmd_TURN(game, n):
     game.turn_complete = False
 cmd_TURN.argtypes = 'int:nonneg'
@@ -187,8 +148,6 @@ def cmd_LOGIN_OK(game):
     game.tui.switch_mode('post_login_wait')
     game.tui.send('GET_GAMESTATE')
     game.tui.log_msg('@ welcome!')
     game.tui.switch_mode('post_login_wait')
     game.tui.send('GET_GAMESTATE')
     game.tui.log_msg('@ welcome!')
-    game.tui.log_msg('@ hint: see top of terminal for how to get help.')
-    game.tui.log_msg('@ hint: enter study mode to understand your environment.')
 cmd_LOGIN_OK.argtypes = ''
 
 def cmd_ADMIN_OK(game):
 cmd_LOGIN_OK.argtypes = ''
 
 def cmd_ADMIN_OK(game):
@@ -343,6 +302,7 @@ cmd_TASKS.argtypes = 'string'
 
 def cmd_THING_TYPE(game, thing_type, symbol_hint):
     game.thing_types[thing_type] = symbol_hint
 
 def cmd_THING_TYPE(game, thing_type, symbol_hint):
     game.thing_types[thing_type] = symbol_hint
+    game.train_parser()
 cmd_THING_TYPE.argtypes = 'string char'
 
 def cmd_THING_INSTALLED(game, thing_id):
 cmd_THING_TYPE.argtypes = 'string char'
 
 def cmd_THING_INSTALLED(game, thing_id):
@@ -424,13 +384,14 @@ class Game(GameBase):
         self.portals_new = {}
         self.terrains = {}
         self.player = None
         self.portals_new = {}
         self.terrains = {}
         self.player = None
+        self.parser = Parser(self)
+        self.train_parser()
 
 
-    def get_string_options(self, string_option_type):
-        if string_option_type == 'map_geometry':
-            return ['Hex', 'Square']
-        elif string_option_type == 'thing_type':
-            return self.thing_types.keys()
-        return None
+    def train_parser(self):
+        self.parser.string_options = {
+            'map_geometry': {'Hex', 'Square'},
+            'thing_type': self.thing_types.keys()
+        }
 
     def get_command(self, command_name):
         from functools import partial
 
     def get_command(self, command_name):
         from functools import partial
@@ -483,7 +444,7 @@ class Mode:
                 return True
         return False
 
                 return True
         return False
 
-class TUI:
+class RogueChatTUI(TUI):
     mode_admin_enter = Mode('admin_enter', has_input_prompt=True)
     mode_admin = Mode('admin')
     mode_play = Mode('play')
     mode_admin_enter = Mode('admin_enter', has_input_prompt=True)
     mode_admin = Mode('admin')
     mode_play = Mode('play')
@@ -511,7 +472,7 @@ class TUI:
     is_admin = False
     tile_draw = False
 
     is_admin = False
     tile_draw = False
 
-    def __init__(self, host):
+    def __init__(self, host, *args, **kwargs):
         import os
         import json
         self.mode_play.available_modes = ["chat", "study", "edit", "admin_enter",
         import os
         import json
         self.mode_play.available_modes = ["chat", "study", "edit", "admin_enter",
@@ -535,17 +496,12 @@ class TUI:
         self.mode_edit.available_actions = ["move", "flatten", "install",
                                             "toggle_map_mode"]
         self.mode = None
         self.mode_edit.available_actions = ["move", "flatten", "install",
                                             "toggle_map_mode"]
         self.mode = None
-        self.host = host
+        self.socket = ClientSocket(host, self.socket_log)
         self.game = Game()
         self.game.tui = self
         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.map_mode = 'terrain + things'
         self.password = 'foo'
         self.login_name = None
         self.map_mode = 'terrain + things'
         self.password = 'foo'
-        self.switch_mode('waiting_for_server')
         self.keys = {
             'switch_to_chat': 't',
             'switch_to_play': 'p',
         self.keys = {
             'switch_to_chat': 't',
             'switch_to_play': 'p',
@@ -593,8 +549,6 @@ class TUI:
             for k in keys_conf:
                 self.keys[k] = keys_conf[k]
         self.show_help = False
             for k in keys_conf:
                 self.keys[k] = keys_conf[k]
         self.show_help = False
-        self.disconnected = True
-        self.force_instant_connect = True
         self.input_lines = []
         self.fov = ''
         self.flash = False
         self.input_lines = []
         self.fov = ''
         self.flash = False
@@ -602,61 +556,72 @@ class TUI:
         self.ascii_draw_stage = 0
         self.full_ascii_draw = ''
         self.offset = YX(0,0)
         self.ascii_draw_stage = 0
         self.full_ascii_draw = ''
         self.offset = YX(0,0)
-        curses.wrapper(self.loop)
-
-    def connect(self):
+        self.explorer = YX(0, 0)
+        self.input_ = ''
+        self.input_prompt = '> '
+        self.action_descriptions = {
+            'move': 'move',
+            'flatten': 'flatten surroundings',
+            'teleport': 'teleport',
+            'take_thing': 'pick up thing',
+            'drop_thing': 'drop thing',
+            'toggle_map_mode': 'toggle map view',
+            'toggle_tile_draw': 'toggle protection character drawing',
+            'install': '(un-)install',
+            'wear': '(un-)wear',
+            'door': 'open/close',
+            'consume': 'consume',
+            'spin': 'spin',
+            'dance': 'dance',
+        }
+        self.action_tasks = {
+            'flatten': 'FLATTEN_SURROUNDINGS',
+            'take_thing': 'PICK_UP',
+            'drop_thing': 'DROP',
+            'door': 'DOOR',
+            'install': 'INSTALL',
+            'wear': 'WEAR',
+            'move': 'MOVE',
+            'command': 'COMMAND',
+            'consume': 'INTOXICATE',
+            'spin': 'SPIN',
+            'dance': 'DANCE',
+        }
+        super().__init__(*args, **kwargs)
 
 
-        def handle_recv(msg):
-            if msg == 'BYE':
-                self.socket.close()
-            else:
-                self.queue.put(msg)
-
-        self.log_msg('@ attempting connect')
-        socket_client_class = PlomSocketClient
-        if self.host.startswith('ws://') or self.host.startswith('wss://'):
-            socket_client_class = WebSocketClient
-        try:
-            self.socket = socket_client_class(handle_recv, self.host)
-            self.socket_thread = threading.Thread(target=self.socket.run)
-            self.socket_thread.start()
-            self.disconnected = False
-            self.game.thing_types = {}
-            self.game.terrains = {}
-            time.sleep(0.1)  # give potential SSL negotation some time …
-            self.socket.send('TASKS')
-            self.socket.send('TERRAINS')
-            self.socket.send('THING_TYPES')
-            self.switch_mode('login')
-        except ConnectionRefusedError:
-            self.log_msg('@ server connect failure')
-            self.disconnected = True
-            self.switch_mode('waiting_for_server')
-        self.do_refresh = True
+    def update_on_connect(self):
+        self.game.thing_types = {}
+        self.game.terrains = {}
+        self.game.train_parser()
+        self.is_admin = False
+        self.socket.send('TASKS')
+        self.socket.send('TERRAINS')
+        self.socket.send('THING_TYPES')
+        self.switch_mode('login')
 
     def reconnect(self):
 
     def reconnect(self):
-        self.log_msg('@ attempting reconnect')
-        self.send('QUIT')
+        import time
+        self.log('@ attempting reconnect')
+        self.socket.send('QUIT')
         # necessitated by some strange SSL race conditions with ws4py
         time.sleep(0.1)  # FIXME find out why exactly necessary
         self.switch_mode('waiting_for_server')
         # necessitated by some strange SSL race conditions with ws4py
         time.sleep(0.1)  # FIXME find out why exactly necessary
         self.switch_mode('waiting_for_server')
-        self.connect()
+        self.socket.connect()
+        self.update_on_connect()
 
     def send(self, msg):
 
     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.disconnected = True
-            self.force_instant_connect = True
+        self.socket.send(msg)
+        if self.socket.disconnected:
             self.do_refresh = True
 
             self.do_refresh = True
 
+    def socket_log(self, msg):
+        self.log('@ ' + msg)
+
     def log_msg(self, msg):
     def log_msg(self, msg):
-        self.log += [msg]
-        if len(self.log) > 100:
-            self.log = self.log[-100:]
+        super().log(msg)
+        #self.log += [msg]
+        if len(self._log) > 100:
+            self.log = self._log[-100:]
 
     def restore_input_values(self):
         if self.mode.name == 'annotate' and self.explorer in self.game.annotations:
 
     def restore_input_values(self):
         if self.mode.name == 'annotate' and self.explorer in self.game.annotations:
@@ -698,14 +663,16 @@ class TUI:
     def switch_mode(self, mode_name):
 
         def fail(msg, return_mode='play'):
     def switch_mode(self, mode_name):
 
         def fail(msg, return_mode='play'):
-            self.log_msg('? ' + msg)
+            self.log('? ' + msg)
             self.flash = True
             self.switch_mode(return_mode)
 
         if self.mode and self.mode.name == 'control_tile_draw':
             self.flash = True
             self.switch_mode(return_mode)
 
         if self.mode and self.mode.name == 'control_tile_draw':
-            self.log_msg('@ finished tile protection drawing.')
+            self.log('@ finished tile protection drawing.')
         self.draw_face = False
         self.tile_draw = False
         self.draw_face = False
         self.tile_draw = False
+        self.ascii_draw_stage = 0
+        self.full_ascii_draw = ''
         if mode_name == 'command_thing' and\
            (not self.game.player.carrying or
             not self.game.player.carrying.commandable):
         if mode_name == 'command_thing' and\
            (not self.game.player.carrying or
             not self.game.player.carrying.commandable):
@@ -736,14 +703,14 @@ class TUI:
         if self.mode.is_single_char_entry:
             self.show_help = True
         if len(self.mode.intro_msg) > 0:
         if self.mode.is_single_char_entry:
             self.show_help = True
         if len(self.mode.intro_msg) > 0:
-            self.log_msg(self.mode.intro_msg)
+            self.log(self.mode.intro_msg)
         if self.mode.name == 'login':
             if self.login_name:
                 self.send('LOGIN ' + quote(self.login_name))
             else:
         if self.mode.name == 'login':
             if self.login_name:
                 self.send('LOGIN ' + quote(self.login_name))
             else:
-                self.log_msg('@ enter username')
+                self.log('@ enter username')
         elif self.mode.name == 'take_thing':
         elif self.mode.name == 'take_thing':
-            self.log_msg('Portable things in reach for pick-up:')
+            self.log('Portable things in reach for pick-up:')
             directed_moves = {
                 'HERE': YX(0, 0), 'LEFT': YX(0, -1), 'RIGHT': YX(0, 1)
             }
             directed_moves = {
                 'HERE': YX(0, 0), 'LEFT': YX(0, -1), 'RIGHT': YX(0, 1)
             }
@@ -777,37 +744,38 @@ class TUI:
             else:
                 for i in range(len(self.selectables)):
                     t = self.game.get_thing(self.selectables[i])
             else:
                 for i in range(len(self.selectables)):
                     t = self.game.get_thing(self.selectables[i])
-                    self.log_msg('%s %s: %s' % (i, directions[i],
+                    self.log('%s %s: %s' % (i, directions[i],
                                                 self.get_thing_info(t)))
         elif self.mode.name == 'drop_thing':
                                                 self.get_thing_info(t)))
         elif self.mode.name == 'drop_thing':
-            self.log_msg('Direction to drop thing to:')
+            self.log('Direction to drop thing to:')
             self.selectables =\
                 ['HERE'] + list(self.game.tui.movement_keys.values())
             for i in range(len(self.selectables)):
             self.selectables =\
                 ['HERE'] + list(self.game.tui.movement_keys.values())
             for i in range(len(self.selectables)):
-                self.log_msg(str(i) + ': ' + self.selectables[i])
+                self.log(str(i) + ': ' + self.selectables[i])
         elif self.mode.name == 'enter_design':
             if self.game.player.carrying.type_ == 'Hat':
         elif self.mode.name == 'enter_design':
             if self.game.player.carrying.type_ == 'Hat':
-                self.log_msg('@ The design you enter must be %s lines of max %s '
+                self.log('@ The design you enter must be %s lines of max %s '
                              'characters width each'
                              % (self.game.player.carrying.design[0].y,
                                 self.game.player.carrying.design[0].x))
                              'characters width each'
                              % (self.game.player.carrying.design[0].y,
                                 self.game.player.carrying.design[0].x))
-                self.log_msg('@ Legal characters: ' + self.game.players_hat_chars)
-                self.log_msg('@ (Eat cookies to extend the ASCII characters available for drawing.)')
+                self.log('@ Legal characters: ' + self.game.players_hat_chars)
+                self.log('@ (Eat cookies to extend the ASCII characters available for drawing.)')
             else:
             else:
-                self.log_msg('@ Width of first line determines maximum width for remaining design')
-                self.log_msg('@ Finish design by entering an empty line (multiple space characters do not count as empty)')
+                self.log('@ Width of first line determines maximum width for remaining design')
+                self.log('@ Finish design by entering an empty line (multiple space characters do not count as empty)')
         elif self.mode.name == 'command_thing':
             self.send('TASK:COMMAND ' + quote('HELP'))
         elif self.mode.name == 'control_pw_pw':
         elif self.mode.name == 'command_thing':
             self.send('TASK:COMMAND ' + quote('HELP'))
         elif self.mode.name == 'control_pw_pw':
-            self.log_msg('@ enter protection password for "%s":' % self.tile_control_char)
+            self.log('@ enter protection password for "%s":' % self.tile_control_char)
         elif self.mode.name == 'control_tile_draw':
         elif self.mode.name == 'control_tile_draw':
-            self.log_msg('@ can draw protection character "%s", turn drawing on/off with [%s], finish with [%s].' % (self.tile_control_char, self.keys['toggle_tile_draw'], self.keys['switch_to_admin_enter']))
+            self.log('@ can draw protection character "%s", turn drawing on/off with [%s], finish with [%s].' % (self.tile_control_char, self.keys['toggle_tile_draw'], self.keys['switch_to_admin_enter']))
         self.input_ = ""
         self.restore_input_values()
 
     def set_default_colors(self):
         self.input_ = ""
         self.restore_input_values()
 
     def set_default_colors(self):
-        curses.init_color(1, 1000, 1000, 1000)
-        curses.init_color(2, 0, 0, 0)
+        if curses.can_change_color():
+            curses.init_color(7, 1000, 1000, 1000)
+            curses.init_color(0, 0, 0, 0)
         self.do_refresh = True
 
     def set_random_colors(self):
         self.do_refresh = True
 
     def set_random_colors(self):
@@ -816,8 +784,9 @@ class TUI:
             import random
             return int(offset + random.random()*375)
 
             import random
             return int(offset + random.random()*375)
 
-        curses.init_color(1, rand(625), rand(625), rand(625))
-        curses.init_color(2, rand(0), rand(0), rand(0))
+        if curses.can_change_color():
+            curses.init_color(7, rand(625), rand(625), rand(625))
+            curses.init_color(0, rand(0), rand(0), rand(0))
         self.do_refresh = True
 
     def get_info(self):
         self.do_refresh = True
 
     def get_info(self):
@@ -898,57 +867,253 @@ class TUI:
             info += ')'
         return info
 
             info += ')'
         return info
 
-    def loop(self, stdscr):
-        import datetime
+    def reset_size(self):
+        super().reset_size()
+        self.left_window_width = min(52, int(self.size.x / 2))
+        self.right_window_width = self.size.x - self.left_window_width
 
 
-        def safe_addstr(y, x, line):
-            if y < self.size.y - 1 or x + len(line) < self.size.x:
-                stdscr.addstr(y, x, line, curses.color_pair(1))
-            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, curses.color_pair(1))
-                stdscr.insstr(y, self.size.x - 2, ' ')
-                stdscr.addstr(y, x, cut, curses.color_pair(1))
+    def addstr(self, y, x, line, ignore=None):
+        super().addstr(y, x, line, curses.color_pair(1))
 
 
-        def handle_input(msg):
-            command, args = self.parser.parse(msg)
-            command(*args)
+    def init_loop(self):
+        self.switch_mode('waiting_for_server')
+        curses.start_color()
+        self.set_default_colors()
+        curses.init_pair(1, 7, 0)
+        if not curses.can_change_color():
+            self.log('@ unfortunately, your terminal does not seem to '
+                         'support re-definition of colors; you might miss out '
+                         'on some color effects')
+        super().init_loop()
+
+    def recalc_input_lines(self):
+        if not self.mode.has_input_prompt:
+            self.input_lines = []
+        else:
+            self.input_lines = msg_into_lines_of_width(self.input_prompt
+                                                       + self.input_ + '█',
+                                                       self.right_window_width)
+    def draw_history(self):
+        lines = []
+        for line in self._log:
+            lines += msg_into_lines_of_width(line, self.right_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
+            self.addstr(max_y - i - 1, self.left_window_width, lines[i])
+
+    def draw_info(self):
+        info = 'MAP VIEW: %s\n%s' % (self.map_mode, self.get_info())
+        lines = msg_into_lines_of_width(info, self.right_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
+            self.addstr(y, self.left_window_width, lines[i])
+
+    def draw_input(self):
+        y = self.size.y - len(self.input_lines)
+        for i in range(len(self.input_lines)):
+            self.addstr(y, self.left_window_width, self.input_lines[i])
+            y += 1
+
+    def draw_stats(self):
+        stats = 'ENERGY: %s BLADDER: %s' % (self.game.energy,
+                                            self.game.bladder_pressure)
+        self.addstr(0, self.left_window_width, stats)
+
+    def draw_mode(self):
+        help = "hit [%s] for help" % self.keys['help']
+        if self.mode.has_input_prompt:
+            help = "enter /help for help"
+        self.addstr(1, self.left_window_width,
+                    'MODE: %s – %s' % (self.mode.short_desc, help))
+
+    def draw_map(self):
+        if (not self.game.turn_complete) and len(self.map_lines) == 0:
+            return
+        if self.game.turn_complete:
+            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
+                if self.map_mode == 'protections':
+                    map_lines_split += [[c + ' ' for c
+                                         in self.game.map_control_content[start:end]]]
+                else:
+                    map_lines_split += [[c + ' ' for c
+                                         in self.game.map_content[start:end]]]
+            if self.map_mode == 'terrain + annotations':
+                for p in self.game.annotations:
+                    map_lines_split[p.y][p.x] = 'A '
+            elif self.map_mode == 'terrain + things':
+                for p in self.game.portals.keys():
+                    original = map_lines_split[p.y][p.x]
+                    map_lines_split[p.y][p.x] = original[0] + 'P'
+                used_positions = []
+
+                def draw_thing(t, used_positions):
+                    symbol = self.game.thing_types[t.type_]
+                    meta_char = ' '
+                    if hasattr(t, 'thing_char'):
+                        meta_char = t.thing_char
+                    if t.position in used_positions:
+                        meta_char = '+'
+                    if hasattr(t, 'carrying') and t.carrying:
+                        meta_char = '$'
+                    map_lines_split[t.position.y][t.position.x] = symbol + meta_char
+                    used_positions += [t.position]
+
+                for t in [t for t in self.game.things if t.type_ != 'Player']:
+                    draw_thing(t, used_positions)
+                for t in [t for t in self.game.things if t.type_ == 'Player']:
+                    draw_thing(t, used_positions)
+            if self.mode.shows_info or self.mode.name == 'control_tile_draw':
+                map_lines_split[self.explorer.y][self.explorer.x] = '??'
+            elif self.map_mode != 'terrain + things':
+                map_lines_split[self.game.player.position.y]\
+                    [self.game.player.position.x] = '??'
+            self.map_lines = []
+            if type(self.game.map_geometry) == MapGeometryHex:
+                indent = 0
+                for line in map_lines_split:
+                    self.map_lines += [indent * ' ' + ''.join(line)]
+                    indent = 0 if indent else 1
+            else:
+                for line in map_lines_split:
+                    self.map_lines += [''.join(line)]
+            window_center = YX(int(self.size.y / 2),
+                               int(self.left_window_width / 2))
+            center = self.game.player.position
+            if self.mode.shows_info or self.mode.name == 'control_tile_draw':
+                center = self.explorer
+            center = YX(center.y, center.x * 2)
+            self.offset = center - window_center
+            if type(self.game.map_geometry) == MapGeometryHex and self.offset.y % 2:
+                self.offset += YX(0, 1)
+        term_y = max(0, -self.offset.y)
+        term_x = max(0, -self.offset.x)
+        map_y = max(0, self.offset.y)
+        map_x = max(0, self.offset.x)
+        while term_y < self.size.y and map_y < len(self.map_lines):
+            to_draw = self.map_lines[map_y][map_x:self.left_window_width + self.offset.x]
+            self.addstr(term_y, term_x, to_draw)
+            term_y += 1
+            map_y += 1
+
+    def draw_names(self):
+        players = [t for t in self.game.things if t.type_ == 'Player']
+        players.sort(key=lambda t: len(t.name))
+        players.reverse()
+        shrink_offset = max(0, (self.size.y - self.left_window_width // 2) // 2)
+        y = 0
+        for t in players:
+            offset_y = y - shrink_offset
+            max_len = max(5, (self.left_window_width // 2) - (offset_y * 2) - 8)
+            name = t.name[:]
+            if len(name) > max_len:
+                name = name[:max_len - 1] + '…'
+            self.addstr(y, 0, '@%s:%s' % (t.thing_char, name))
+            y += 1
+            if y >= self.size.y:
+                break
+
+    def draw_face_popup(self):
+        t = self.game.get_thing(self.draw_face)
+        if not t or not hasattr(t, 'face'):
+            self.draw_face = False
+            return
+
+        start_x = self.left_window_width - 10
+        def draw_body_part(body_part, end_y):
+            self.addstr(end_y - 3, start_x, '----------')
+            self.addstr(end_y - 2, start_x, '| ' + body_part[0:6] + ' |')
+            self.addstr(end_y - 1, start_x, '| ' + body_part[6:12] + ' |')
+            self.addstr(end_y, start_x, '| ' + body_part[12:18] + ' |')
+
+        if hasattr(t, 'face'):
+            draw_body_part(t.face, self.size.y - 3)
+        if hasattr(t, 'hat'):
+            draw_body_part(t.hat, self.size.y - 6)
+        self.addstr(self.size.y - 2, start_x, '----------')
+        name = t.name[:]
+        if len(name) > 7:
+            name = name[:6 - 1] + '…'
+        self.addstr(self.size.y - 1, start_x, '@%s:%s' % (t.thing_char, name))
+
+    def draw_help(self):
+        content = "%s help\n\n%s\n\n" % (self.mode.short_desc,
+                                         self.mode.help_intro)
+        if len(self.mode.available_actions) > 0:
+            content += "Available actions:\n"
+            for action in self.mode.available_actions:
+                if action in self.action_tasks:
+                    if self.action_tasks[action] not in self.game.tasks:
+                        continue
+                if action == 'move_explorer':
+                    action = 'move'
+                if action == 'move':
+                    key = ','.join(self.movement_keys)
+                else:
+                    key = self.keys[action]
+                content += '[%s] – %s\n' % (key, self.action_descriptions[action])
+            content += '\n'
+        content += self.mode.list_available_modes(self)
+        for i in range(self.size.y):
+            self.addstr(i,
+                        self.left_window_width * (not self.mode.has_input_prompt),
+                        ' ' * self.left_window_width)
+        lines = []
+        for line in content.split('\n'):
+            lines += msg_into_lines_of_width(line, self.right_window_width)
+        for i in range(len(lines)):
+            if i >= self.size.y:
+                break
+            self.addstr(i,
+                        self.left_window_width * (not self.mode.has_input_prompt),
+                        lines[i])
+
+    def draw_screen(self):
+        self.stdscr.bkgd(' ', curses.color_pair(1))
+        self.recalc_input_lines()
+        if self.mode.has_input_prompt:
+            self.draw_input()
+        if self.mode.shows_info:
+            self.draw_info()
+        else:
+            self.draw_history()
+        self.draw_mode()
+        if not self.mode.is_intro:
+            self.draw_stats()
+            self.draw_map()
+        if self.show_help:
+            self.draw_help()
+        if self.mode.name in {'chat', 'play'}:
+            self.draw_names()
+            if self.draw_face:
+                self.draw_face_popup()
+
+    def handle_server_message(self, msg):
+        command, args = self.game.parser.parse(msg)
+        command(*args)
+
+    def on_each_loop_start(self):
+        prev_disconnected = self.socket.disconnected
+        self.socket.keep_connection_alive()
+        if prev_disconnected and not self.socket.disconnected:
+            self.update_on_connect()
+        if self.flash:
+            curses.flash()
+            self.flash = False
+
+    def on_key(self, key, keycode):
 
         def task_action_on(action):
 
         def task_action_on(action):
-            return action_tasks[action] in self.game.tasks
-
-        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":
-                        x -= 1
-                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)
+            return self.action_tasks[action] in self.game.tasks
 
         def move_explorer(direction):
             target = self.game.map_geometry.move_yx(self.explorer, direction)
 
         def move_explorer(direction):
             target = self.game.map_geometry.move_yx(self.explorer, direction)
@@ -959,203 +1124,15 @@ class TUI:
                     self.send_tile_control_command()
             else:
                 self.flash = True
                     self.send_tile_control_command()
             else:
                 self.flash = True
-
-        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():
-            info = 'MAP VIEW: %s\n%s' % (self.map_mode, self.get_info())
-            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_stats():
-            stats = 'ENERGY: %s BLADDER: %s' % (self.game.energy,
-                                                self.game.bladder_pressure)
-            safe_addstr(0, self.window_width, stats)
-
-        def draw_mode():
-            help = "hit [%s] for help" % self.keys['help']
-            if self.mode.has_input_prompt:
-                help = "enter /help for help"
-            safe_addstr(1, self.window_width,
-                        'MODE: %s – %s' % (self.mode.short_desc, help))
-
-        def draw_map():
-            if (not self.game.turn_complete) and len(self.map_lines) == 0:
-                return
-            if self.game.turn_complete:
-                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
-                    if self.map_mode == 'protections':
-                        map_lines_split += [[c + ' ' for c
-                                             in self.game.map_control_content[start:end]]]
-                    else:
-                        map_lines_split += [[c + ' ' for c
-                                             in self.game.map_content[start:end]]]
-                if self.map_mode == 'terrain + annotations':
-                    for p in self.game.annotations:
-                        map_lines_split[p.y][p.x] = 'A '
-                elif self.map_mode == 'terrain + things':
-                    for p in self.game.portals.keys():
-                        original = map_lines_split[p.y][p.x]
-                        map_lines_split[p.y][p.x] = original[0] + 'P'
-                    used_positions = []
-
-                    def draw_thing(t, used_positions):
-                        symbol = self.game.thing_types[t.type_]
-                        meta_char = ' '
-                        if hasattr(t, 'thing_char'):
-                            meta_char = t.thing_char
-                        if t.position in used_positions:
-                            meta_char = '+'
-                        if hasattr(t, 'carrying') and t.carrying:
-                            meta_char = '$'
-                        map_lines_split[t.position.y][t.position.x] = symbol + meta_char
-                        used_positions += [t.position]
-
-                    for t in [t for t in self.game.things if t.type_ != 'Player']:
-                        draw_thing(t, used_positions)
-                    for t in [t for t in self.game.things if t.type_ == 'Player']:
-                        draw_thing(t, used_positions)
-                if self.mode.shows_info or self.mode.name == 'control_tile_draw':
-                    map_lines_split[self.explorer.y][self.explorer.x] = '??'
-                elif self.map_mode != 'terrain + things':
-                    map_lines_split[self.game.player.position.y]\
-                        [self.game.player.position.x] = '??'
-                self.map_lines = []
-                if type(self.game.map_geometry) == MapGeometryHex:
-                    indent = 0
-                    for line in map_lines_split:
-                        self.map_lines += [indent * ' ' + ''.join(line)]
-                        indent = 0 if indent else 1
-                else:
-                    for line in map_lines_split:
-                        self.map_lines += [''.join(line)]
-                window_center = YX(int(self.size.y / 2),
-                                   int(self.window_width / 2))
-                center = self.game.player.position
-                if self.mode.shows_info or self.mode.name == 'control_tile_draw':
-                    center = self.explorer
-                center = YX(center.y, center.x * 2)
-                self.offset = center - window_center
-                if type(self.game.map_geometry) == MapGeometryHex and self.offset.y % 2:
-                    self.offset += YX(0, 1)
-            term_y = max(0, -self.offset.y)
-            term_x = max(0, -self.offset.x)
-            map_y = max(0, self.offset.y)
-            map_x = max(0, self.offset.x)
-            while term_y < self.size.y and map_y < len(self.map_lines):
-                to_draw = self.map_lines[map_y][map_x:self.window_width + self.offset.x]
-                safe_addstr(term_y, term_x, to_draw)
-                term_y += 1
-                map_y += 1
-
-        def draw_face_popup():
-            t = self.game.get_thing(self.draw_face)
-            if not t or not hasattr(t, 'face'):
-                self.draw_face = False
-                return
-
-            start_x = self.window_width - 10
-            def draw_body_part(body_part, end_y):
-                safe_addstr(end_y - 3, start_x, '----------')
-                safe_addstr(end_y - 2, start_x, '| ' + body_part[0:6] + ' |')
-                safe_addstr(end_y - 1, start_x, '| ' + body_part[6:12] + ' |')
-                safe_addstr(end_y, start_x, '| ' + body_part[12:18] + ' |')
-
-            if hasattr(t, 'face'):
-                draw_body_part(t.face, self.size.y - 3)
-            if hasattr(t, 'hat'):
-                draw_body_part(t.hat, self.size.y - 6)
-            safe_addstr(self.size.y - 2, start_x, '----------')
-            name = t.name[:]
-            if len(name) > 6:
-                name = name[:6] + '…'
-            safe_addstr(self.size.y - 1, start_x,
-                        '@%s:%s' % (t.thing_char, name))
-
-        def draw_help():
-            content = "%s help\n\n%s\n\n" % (self.mode.short_desc,
-                                             self.mode.help_intro)
-            if len(self.mode.available_actions) > 0:
-                content += "Available actions:\n"
-                for action in self.mode.available_actions:
-                    if action in action_tasks:
-                        if action_tasks[action] not in self.game.tasks:
-                            continue
-                    if action == 'move_explorer':
-                        action = 'move'
-                    if action == 'move':
-                        key = ','.join(self.movement_keys)
-                    else:
-                        key = self.keys[action]
-                    content += '[%s] – %s\n' % (key, action_descriptions[action])
-                content += '\n'
-            content += self.mode.list_available_modes(self)
-            for i in range(self.size.y):
-                safe_addstr(i,
-                            self.window_width * (not self.mode.has_input_prompt),
-                            ' ' * self.window_width)
-            lines = []
-            for line in content.split('\n'):
-                lines += msg_into_lines_of_width(line, self.window_width)
-            for i in range(len(lines)):
-                if i >= self.size.y:
-                    break
-                safe_addstr(i,
-                            self.window_width * (not self.mode.has_input_prompt),
-                            lines[i])
-
-        def draw_screen():
-            stdscr.clear()
-            stdscr.bkgd(' ', curses.color_pair(1))
-            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_stats()
-                draw_map()
-            if self.show_help:
-                draw_help()
-            if self.draw_face and self.mode.name in {'chat', 'play'}:
-                draw_face_popup()
-
         def pick_selectable(task_name):
             try:
                 i = int(self.input_)
                 if i < 0 or i >= len(self.selectables):
         def pick_selectable(task_name):
             try:
                 i = int(self.input_)
                 if i < 0 or i >= len(self.selectables):
-                    self.log_msg('? invalid index, aborted')
+                    self.log('? invalid index, aborted')
                 else:
                     self.send('TASK:%s %s' % (task_name, self.selectables[i]))
             except ValueError:
                 else:
                     self.send('TASK:%s %s' % (task_name, self.selectables[i]))
             except ValueError:
-                self.log_msg('? invalid index, aborted')
+                self.log('? invalid index, aborted')
             self.input_ = ''
             self.switch_mode('play')
 
             self.input_ = ''
             self.switch_mode('play')
 
@@ -1164,7 +1141,7 @@ class TUI:
             if with_size and self.ascii_draw_stage == 0:
                 width = len(self.input_)
                 if width > 36:
             if with_size and self.ascii_draw_stage == 0:
                 width = len(self.input_)
                 if width > 36:
-                    self.log_msg('? input too long, must be max 36; try again')
+                    self.log('? input too long, must be max 36; try again')
                     # TODO: move max width mechanism server-side
                     return
                 old_size = self.game.player.carrying.design[0]
                     # TODO: move max width mechanism server-side
                     return
                 old_size = self.game.player.carrying.design[0]
@@ -1173,10 +1150,10 @@ class TUI:
                     self.game.player.carrying.design[1] = ''
                     self.game.player.carrying.design[0] = YX(old_size.y, width)
             elif len(self.input_) > width:
                     self.game.player.carrying.design[1] = ''
                     self.game.player.carrying.design[0] = YX(old_size.y, width)
             elif len(self.input_) > width:
-                self.log_msg('? input too long, '
+                self.log('? input too long, '
                              'must be max %s; try again' % width)
                 return
                              'must be max %s; try again' % width)
                 return
-            self.log_msg('  ' + self.input_)
+            self.log('  ' + self.input_)
             if with_size and self.input_ in {'', ' '}\
                and self.ascii_draw_stage > 0:
                 height = self.ascii_draw_stage
             if with_size and self.input_ in {'', ' '}\
                and self.ascii_draw_stage > 0:
                 height = self.ascii_draw_stage
@@ -1206,263 +1183,183 @@ class TUI:
                 self.input_ = ""
                 self.switch_mode('edit')
 
                 self.input_ = ""
                 self.switch_mode('edit')
 
-        action_descriptions = {
-            'move': 'move',
-            'flatten': 'flatten surroundings',
-            'teleport': 'teleport',
-            'take_thing': 'pick up thing',
-            'drop_thing': 'drop thing',
-            'toggle_map_mode': 'toggle map view',
-            'toggle_tile_draw': 'toggle protection character drawing',
-            'install': '(un-)install',
-            'wear': '(un-)wear',
-            'door': 'open/close',
-            'consume': 'consume',
-            'spin': 'spin',
-            'dance': 'dance',
-        }
-
-        action_tasks = {
-            'flatten': 'FLATTEN_SURROUNDINGS',
-            'take_thing': 'PICK_UP',
-            'drop_thing': 'DROP',
-            'door': 'DOOR',
-            'install': 'INSTALL',
-            'wear': 'WEAR',
-            'move': 'MOVE',
-            'command': 'COMMAND',
-            'consume': 'INTOXICATE',
-            'spin': 'SPIN',
-            'dance': 'DANCE',
-        }
-
-        curses.curs_set(False)  # hide cursor
-        curses.start_color()
-        self.set_default_colors()
-        curses.init_pair(1, 1, 2)
-        stdscr.timeout(10)
-        reset_screen_size()
-        self.explorer = YX(0, 0)
-        self.input_ = ''
-        store_widechar = False
-        input_prompt = '> '
-        interval = datetime.timedelta(seconds=5)
-        last_ping = datetime.datetime.now() - interval
-        while True:
-            if self.disconnected and self.force_instant_connect:
-                self.force_instant_connect = False
-                self.connect()
-            now = datetime.datetime.now()
-            if now - last_ping > interval:
-                if self.disconnected:
-                    self.connect()
-                else:
-                    self.send('PING')
-                last_ping = now
-            if self.flash:
-                curses.flash()
-                self.flash = False
-            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
-            keycode = None
-            if len(key) == 1:
-                keycode = ord(key)
-                # workaround for <https://stackoverflow.com/a/56390915>
-                if store_widechar:
-                    store_widechar = False
-                    key = bytes([195, keycode]).decode()
-                if keycode == 195:
-                    store_widechar = True
-                    continue
-            self.show_help = False
-            self.draw_face = False
-            if key == 'KEY_RESIZE':
-                reset_screen_size()
-            elif self.mode.has_input_prompt and key == 'KEY_BACKSPACE':
-                self.input_ = self.input_[:-1]
-            elif (((not self.mode.is_intro) and keycode == 27)  # Escape
-                  or (self.mode.has_input_prompt and key == '\n'
-                      and self.input_ == ''\
-                      and self.mode.name in {'chat', 'command_thing',
-                                             'take_thing', 'drop_thing',
-                                             'admin_enter'})):
-                if self.mode.name not in {'chat', 'play', 'study', 'edit'}:
-                    self.log_msg('@ aborted')
-                self.switch_mode('play')
-            elif self.mode.has_input_prompt and key == '\n' and self.input_ == '/help':
-                self.show_help = True
-                self.input_ = ""
-                self.restore_input_values()
-            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 key == self.keys['help'] and not self.mode.is_single_char_entry:
-                self.show_help = True
-            elif self.mode.name == 'login' and key == '\n':
-                self.login_name = self.input_
-                self.send('LOGIN ' + quote(self.input_))
-                self.input_ = ""
-            elif self.mode.name == 'enter_face' and key == '\n':
-                enter_ascii_art('PLAYER_FACE', 3, 6)
-            elif self.mode.name == 'enter_design' and key == '\n':
-                if self.game.player.carrying.type_ == 'Hat':
-                    enter_ascii_art('THING_DESIGN',
-                                    self.game.player.carrying.design[0].y,
-                                    self.game.player.carrying.design[0].x, True)
-                else:
-                    enter_ascii_art('THING_DESIGN',
-                                    self.game.player.carrying.design[0].y,
-                                    self.game.player.carrying.design[0].x,
-                                    True, True)
-            elif self.mode.name == 'take_thing' and key == '\n':
-                pick_selectable('PICK_UP')
-            elif self.mode.name == 'drop_thing' and key == '\n':
-                pick_selectable('DROP')
-            elif self.mode.name == 'command_thing' and key == '\n':
-                self.send('TASK:COMMAND ' + quote(self.input_))
-                self.input_ = ""
-            elif self.mode.name == 'control_pw_pw' and key == '\n':
-                if self.input_ == '':
-                    self.log_msg('@ aborted')
-                else:
-                    self.send('SET_MAP_CONTROL_PASSWORD ' + quote(self.tile_control_char) + ' ' + quote(self.input_))
-                    self.log_msg('@ sent new password for protection character "%s"' % self.tile_control_char)
+        self.show_help = False
+        self.draw_face = False
+        if key == 'KEY_RESIZE':
+            self.reset_size()
+        elif self.mode.has_input_prompt and key == 'KEY_BACKSPACE':
+            self.input_ = self.input_[:-1]
+        elif (((not self.mode.is_intro) and keycode == 27)  # Escape
+              or (self.mode.has_input_prompt and key == '\n'
+                  and self.input_ == ''\
+                  and self.mode.name in {'chat', 'command_thing',
+                                         'take_thing', 'drop_thing',
+                                         'admin_enter'})):
+            if self.mode.name not in {'chat', 'play', 'study', 'edit'}:
+                self.log('@ aborted')
+            self.switch_mode('play')
+        elif self.mode.has_input_prompt and key == '\n' and self.input_ == '/help':
+            self.show_help = True
+            self.input_ = ""
+            self.restore_input_values()
+        elif self.mode.has_input_prompt and key != '\n':  # Return key
+            self.input_ += key
+            max_length = self.right_window_width * self.size.y - len(self.input_prompt) - 1
+            if len(self.input_) > max_length:
+                self.input_ = self.input_[:max_length]
+        elif key == self.keys['help'] and not self.mode.is_single_char_entry:
+            self.show_help = True
+        elif self.mode.name == 'login' and key == '\n':
+            self.login_name = self.input_
+            self.send('LOGIN ' + quote(self.input_))
+            self.input_ = ""
+        elif self.mode.name == 'enter_face' and key == '\n':
+            enter_ascii_art('PLAYER_FACE', 3, 6)
+        elif self.mode.name == 'enter_design' and key == '\n':
+            if self.game.player.carrying.type_ == 'Hat':
+                enter_ascii_art('THING_DESIGN',
+                                self.game.player.carrying.design[0].y,
+                                self.game.player.carrying.design[0].x, True)
+            else:
+                enter_ascii_art('THING_DESIGN',
+                                self.game.player.carrying.design[0].y,
+                                self.game.player.carrying.design[0].x,
+                                True, True)
+        elif self.mode.name == 'take_thing' and key == '\n':
+            pick_selectable('PICK_UP')
+        elif self.mode.name == 'drop_thing' and key == '\n':
+            pick_selectable('DROP')
+        elif self.mode.name == 'command_thing' and key == '\n':
+            self.send('TASK:COMMAND ' + quote(self.input_))
+            self.input_ = ""
+        elif self.mode.name == 'control_pw_pw' and key == '\n':
+            if self.input_ == '':
+                self.log('@ aborted')
+            else:
+                self.send('SET_MAP_CONTROL_PASSWORD ' + quote(self.tile_control_char) + ' ' + quote(self.input_))
+                self.log('@ sent new password for protection character "%s"' % self.tile_control_char)
+            self.switch_mode('admin')
+        elif self.mode.name == 'password' and key == '\n':
+            if self.input_ == '':
+                self.input_ = ' '
+            self.password = self.input_
+            self.switch_mode('edit')
+        elif self.mode.name == 'admin_enter' and key == '\n':
+            self.send('BECOME_ADMIN ' + quote(self.input_))
+            self.switch_mode('play')
+        elif self.mode.name == 'control_pw_type' and key == '\n':
+            if len(self.input_) != 1:
+                self.log('@ entered non-single-char, therefore aborted')
                 self.switch_mode('admin')
                 self.switch_mode('admin')
-            elif self.mode.name == 'password' and key == '\n':
-                if self.input_ == '':
-                    self.input_ = ' '
-                self.password = self.input_
-                self.switch_mode('edit')
-            elif self.mode.name == 'admin_enter' and key == '\n':
-                self.send('BECOME_ADMIN ' + quote(self.input_))
-                self.switch_mode('play')
-            elif self.mode.name == 'control_pw_type' and key == '\n':
-                if len(self.input_) != 1:
-                    self.log_msg('@ entered non-single-char, therefore aborted')
-                    self.switch_mode('admin')
-                else:
-                    self.tile_control_char = self.input_
-                    self.switch_mode('control_pw_pw')
-            elif self.mode.name == 'admin_thing_protect' and key == '\n':
-                if len(self.input_) != 1:
-                    self.log_msg('@ entered non-single-char, therefore aborted')
-                else:
-                    self.send('THING_PROTECTION %s' % (quote(self.input_)))
-                    self.log_msg('@ sent new protection character for thing')
+            else:
+                self.tile_control_char = self.input_
+                self.switch_mode('control_pw_pw')
+        elif self.mode.name == 'admin_thing_protect' and key == '\n':
+            if len(self.input_) != 1:
+                self.log('@ entered non-single-char, therefore aborted')
+            else:
+                self.send('THING_PROTECTION %s' % (quote(self.input_)))
+                self.log('@ sent new protection character for thing')
+            self.switch_mode('admin')
+        elif self.mode.name == 'control_tile_type' and key == '\n':
+            if len(self.input_) != 1:
+                self.log('@ entered non-single-char, therefore aborted')
                 self.switch_mode('admin')
                 self.switch_mode('admin')
-            elif self.mode.name == 'control_tile_type' and key == '\n':
-                if len(self.input_) != 1:
-                    self.log_msg('@ entered non-single-char, therefore aborted')
-                    self.switch_mode('admin')
-                else:
-                    self.tile_control_char = self.input_
-                    self.switch_mode('control_tile_draw')
-            elif self.mode.name == 'chat' and key == '\n':
-                if self.input_ == '':
-                    continue
-                if self.input_[0] == '/':
-                    if self.input_.startswith('/nick'):
-                        tokens = self.input_.split(maxsplit=1)
-                        if len(tokens) == 2:
-                            self.send('NICK ' + quote(tokens[1]))
-                        else:
-                            self.log_msg('? need login name')
+            else:
+                self.tile_control_char = self.input_
+                self.switch_mode('control_tile_draw')
+        elif self.mode.name == 'chat' and key == '\n':
+            if self.input_ == '':
+                return
+            if self.input_[0] == '/':
+                if self.input_.startswith('/nick'):
+                    tokens = self.input_.split(maxsplit=1)
+                    if len(tokens) == 2:
+                        self.send('NICK ' + quote(tokens[1]))
                     else:
                     else:
-                        self.log_msg('? unknown command')
+                        self.log('? need login name')
                 else:
                 else:
-                    self.send('ALL ' + quote(self.input_))
-                self.input_ = ""
-            elif self.mode.name == 'name_thing' and key == '\n':
-                if self.input_ == '':
-                    self.input_ = ' '
-                self.send('THING_NAME %s %s' % (quote(self.input_),
-                                                quote(self.password)))
-                self.switch_mode('edit')
-            elif self.mode.name == 'annotate' and key == '\n':
-                if self.input_ == '':
-                    self.input_ = ' '
-                self.send('ANNOTATE %s %s %s' % (self.explorer, quote(self.input_),
-                                                 quote(self.password)))
-                self.switch_mode('edit')
-            elif self.mode.name == 'portal' and key == '\n':
-                if self.input_ == '':
-                    self.input_ = ' '
-                self.send('PORTAL %s %s %s' % (self.explorer, quote(self.input_),
-                                               quote(self.password)))
-                self.switch_mode('edit')
-            elif self.mode.name == 'study':
-                if self.mode.mode_switch_on_key(self, key):
-                    continue
-                elif key == self.keys['toggle_map_mode']:
-                    self.toggle_map_mode()
-                elif key in self.movement_keys:
-                    move_explorer(self.movement_keys[key])
-            elif self.mode.name == 'play':
-                if self.mode.mode_switch_on_key(self, key):
-                    continue
-                elif key == self.keys['door'] and task_action_on('door'):
-                    self.send('TASK:DOOR')
-                elif key == self.keys['consume'] and task_action_on('consume'):
-                    self.send('TASK:INTOXICATE')
-                elif key == self.keys['wear'] and task_action_on('wear'):
-                    self.send('TASK:WEAR')
-                elif key == self.keys['spin'] and task_action_on('spin'):
-                    self.send('TASK:SPIN')
-                elif key == self.keys['dance'] and task_action_on('dance'):
-                    self.send('TASK:DANCE')
-                elif key == self.keys['teleport']:
-                    if self.game.player.position in self.game.portals:
-                        self.host = self.game.portals[self.game.player.position]
-                        self.reconnect()
-                    else:
-                        self.flash = True
-                        self.log_msg('? not standing on portal')
-                elif key in self.movement_keys and task_action_on('move'):
-                    self.send('TASK:MOVE ' + self.movement_keys[key])
-            elif self.mode.name == 'write':
-                self.send('TASK:WRITE %s %s' % (key, quote(self.password)))
-                self.switch_mode('edit')
-            elif self.mode.name == 'control_tile_draw':
-                if self.mode.mode_switch_on_key(self, key):
-                    continue
-                elif key in self.movement_keys:
-                    move_explorer(self.movement_keys[key])
-                elif key == self.keys['toggle_tile_draw']:
-                    self.tile_draw = False if self.tile_draw else True
-            elif self.mode.name == 'admin':
-                if self.mode.mode_switch_on_key(self, key):
-                    continue
-                elif key == self.keys['toggle_map_mode']:
-                    self.toggle_map_mode()
-                elif key in self.movement_keys and task_action_on('move'):
-                    self.send('TASK:MOVE ' + self.movement_keys[key])
-            elif self.mode.name == 'edit':
-                if self.mode.mode_switch_on_key(self, key):
-                    continue
-                elif key == self.keys['flatten'] and task_action_on('flatten'):
-                    self.send('TASK:FLATTEN_SURROUNDINGS ' + quote(self.password))
-                elif key == self.keys['install'] and task_action_on('install'):
-                    self.send('TASK:INSTALL %s' % quote(self.password))
-                elif key == self.keys['toggle_map_mode']:
-                    self.toggle_map_mode()
-                elif key in self.movement_keys and task_action_on('move'):
-                    self.send('TASK:MOVE ' + self.movement_keys[key])
+                    self.log('? unknown command')
+            else:
+                self.send('ALL ' + quote(self.input_))
+            self.input_ = ""
+        elif self.mode.name == 'name_thing' and key == '\n':
+            if self.input_ == '':
+                self.input_ = ' '
+            self.send('THING_NAME %s %s' % (quote(self.input_),
+                                            quote(self.password)))
+            self.switch_mode('edit')
+        elif self.mode.name == 'annotate' and key == '\n':
+            if self.input_ == '':
+                self.input_ = ' '
+            self.send('ANNOTATE %s %s %s' % (self.explorer, quote(self.input_),
+                                             quote(self.password)))
+            self.switch_mode('edit')
+        elif self.mode.name == 'portal' and key == '\n':
+            if self.input_ == '':
+                self.input_ = ' '
+            self.send('PORTAL %s %s %s' % (self.explorer, quote(self.input_),
+                                           quote(self.password)))
+            self.switch_mode('edit')
+        elif self.mode.name == 'study':
+            if self.mode.mode_switch_on_key(self, key):
+                return
+            elif key == self.keys['toggle_map_mode']:
+                self.toggle_map_mode()
+            elif key in self.movement_keys:
+                move_explorer(self.movement_keys[key])
+        elif self.mode.name == 'play':
+            if self.mode.mode_switch_on_key(self, key):
+                return
+            elif key == self.keys['door'] and task_action_on('door'):
+                self.send('TASK:DOOR')
+            elif key == self.keys['consume'] and task_action_on('consume'):
+                self.send('TASK:INTOXICATE')
+            elif key == self.keys['wear'] and task_action_on('wear'):
+                self.send('TASK:WEAR')
+            elif key == self.keys['spin'] and task_action_on('spin'):
+                self.send('TASK:SPIN')
+            elif key == self.keys['dance'] and task_action_on('dance'):
+                self.send('TASK:DANCE')
+            elif key == self.keys['teleport']:
+                if self.game.player.position in self.game.portals:
+                    self.socket.host = self.game.portals[self.game.player.position]
+                    self.reconnect()
+                else:
+                    self.flash = True
+                    self.log('? not standing on portal')
+            elif key in self.movement_keys and task_action_on('move'):
+                self.send('TASK:MOVE ' + self.movement_keys[key])
+        elif self.mode.name == 'write':
+            self.send('TASK:WRITE %s %s' % (key, quote(self.password)))
+            self.switch_mode('edit')
+        elif self.mode.name == 'control_tile_draw':
+            if self.mode.mode_switch_on_key(self, key):
+                return
+            elif key in self.movement_keys:
+                move_explorer(self.movement_keys[key])
+            elif key == self.keys['toggle_tile_draw']:
+                self.tile_draw = False if self.tile_draw else True
+        elif self.mode.name == 'admin':
+            if self.mode.mode_switch_on_key(self, key):
+                return
+            elif key == self.keys['toggle_map_mode']:
+                self.toggle_map_mode()
+            elif key in self.movement_keys and task_action_on('move'):
+                self.send('TASK:MOVE ' + self.movement_keys[key])
+        elif self.mode.name == 'edit':
+            if self.mode.mode_switch_on_key(self, key):
+                return
+            elif key == self.keys['flatten'] and task_action_on('flatten'):
+                self.send('TASK:FLATTEN_SURROUNDINGS ' + quote(self.password))
+            elif key == self.keys['install'] and task_action_on('install'):
+                self.send('TASK:INSTALL %s' % quote(self.password))
+            elif key == self.keys['toggle_map_mode']:
+                self.toggle_map_mode()
+            elif key in self.movement_keys and task_action_on('move'):
+                self.send('TASK:MOVE ' + self.movement_keys[key])
 
 if len(sys.argv) != 2:
     raise ArgError('wrong number of arguments, need game host')
 host = sys.argv[1]
 
 if len(sys.argv) != 2:
     raise ArgError('wrong number of arguments, need game host')
 host = sys.argv[1]
-TUI(host)
+RogueChatTUI(host)