home · contact · privacy
Refactor parser code.
[plomrogue2] / rogue_chat_curses.py
index ff559246fe1cbdf22aec2879a41ecf3b7ab3777a..4f6981df553696fb7d865f76453463a819e580dd 100755 (executable)
@@ -1,15 +1,16 @@
 #!/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
-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': {
@@ -53,13 +54,13 @@ mode_helps = {
     },
     'enter_face': {
         'short': 'edit face',
-        'intro': '@ enter face line (enter nothing to abort):',
+        'intro': '@ enter face line:',
         'long': 'Draw your face as ASCII art.  The string you enter must be 18 characters long, and will be divided on display into 3 lines of 6 characters each, from top to bottom..'
     },
-    'enter_hat': {
-        'short': 'edit hat',
-        'intro': '@ enter hat line (enter nothing to abort):',
-        'long': 'Draw your face as ASCII art.  The string you enter must be 18 characters long, and will be divided on display into 3 lines of 6 characters each, from top to bottom.  Eat cookies to extend the ASCII characters available for drawing.'
+    'enter_design': {
+        'short': 'edit design',
+        'intro': '@ enter design:',
+        'long': 'Enter design for carried thing as ASCII art.'
     },
     'write': {
         'short': 'edit tile',
@@ -133,48 +134,7 @@ 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 = n
     game.turn_complete = False
 cmd_TURN.argtypes = 'int:nonneg'
 
@@ -187,7 +147,7 @@ cmd_OTHER_WIPE.argtypes = ''
 def cmd_LOGIN_OK(game):
     game.tui.switch_mode('post_login_wait')
     game.tui.send('GET_GAMESTATE')
-    game.tui.log_msg('@ welcome')
+    game.tui.log_msg('@ welcome!')
 cmd_LOGIN_OK.argtypes = ''
 
 def cmd_ADMIN_OK(game):
@@ -247,6 +207,11 @@ def cmd_THING_HAT(game, thing_id, hat):
     t.hat = hat
 cmd_THING_HAT.argtypes = 'int:pos string'
 
+def cmd_THING_DESIGN(game, thing_id, size, design):
+    t = game.get_thing_temp(thing_id)
+    t.design = [size, design]
+cmd_THING_DESIGN.argtypes = 'int:pos yx_tuple string'
+
 def cmd_THING_CHAR(game, thing_id, c):
     t = game.get_thing_temp(thing_id)
     t.thing_char = c
@@ -294,6 +259,8 @@ def cmd_GAME_STATE_COMPLETE(game):
     game.map_control_content = game.map_control_content_new
     game.player = game.get_thing(game.player_id)
     game.players_hat_chars = game.players_hat_chars_new
+    game.bladder_pressure = game.bladder_pressure_new
+    game.energy = game.energy_new
     game.turn_complete = True
     if game.tui.mode.name == 'post_login_wait':
         game.tui.switch_mode('play')
@@ -335,6 +302,7 @@ cmd_TASKS.argtypes = 'string'
 
 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):
@@ -361,6 +329,11 @@ def cmd_RANDOM_COLORS(game):
     game.tui.set_random_colors()
 cmd_RANDOM_COLORS.argtypes = ''
 
+def cmd_STATS(game, bladder_pressure, energy):
+    game.bladder_pressure_new = bladder_pressure
+    game.energy_new = energy
+cmd_STATS.argtypes = 'int:nonneg int'
+
 class Game(GameBase):
     turn_complete = False
     tasks = {}
@@ -384,6 +357,7 @@ class Game(GameBase):
         self.register_command(cmd_THING_CHAR)
         self.register_command(cmd_THING_FACE)
         self.register_command(cmd_THING_HAT)
+        self.register_command(cmd_THING_DESIGN)
         self.register_command(cmd_THING_CARRYING)
         self.register_command(cmd_THING_INSTALLED)
         self.register_command(cmd_TERRAIN)
@@ -400,6 +374,7 @@ class Game(GameBase):
         self.register_command(cmd_FOV)
         self.register_command(cmd_DEFAULT_COLORS)
         self.register_command(cmd_RANDOM_COLORS)
+        self.register_command(cmd_STATS)
         self.map_content = ''
         self.players_hat_chars = ''
         self.player_id = -1
@@ -409,13 +384,14 @@ class Game(GameBase):
         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
@@ -468,7 +444,7 @@ class Mode:
                 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')
@@ -492,45 +468,40 @@ class TUI:
     mode_take_thing = Mode('take_thing', has_input_prompt=True)
     mode_drop_thing = Mode('drop_thing', has_input_prompt=True)
     mode_enter_face = Mode('enter_face', has_input_prompt=True)
-    mode_enter_hat = Mode('enter_hat', has_input_prompt=True)
+    mode_enter_design = Mode('enter_design', has_input_prompt=True)
     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",
                                           "command_thing", "take_thing",
                                           "drop_thing"]
         self.mode_play.available_actions = ["move", "teleport", "door", "consume",
-                                            "install", "wear", "spin"]
+                                            "install", "wear", "spin", "dance"]
         self.mode_study.available_modes = ["chat", "play", "admin_enter", "edit"]
         self.mode_study.available_actions = ["toggle_map_mode", "move_explorer"]
         self.mode_admin.available_modes = ["admin_thing_protect", "control_pw_type",
                                            "control_tile_type", "chat",
                                            "study", "play", "edit"]
-        self.mode_admin.available_actions = ["move"]
+        self.mode_admin.available_actions = ["move", "toggle_map_mode"]
         self.mode_control_tile_draw.available_modes = ["admin_enter"]
         self.mode_control_tile_draw.available_actions = ["move_explorer",
                                                          "toggle_tile_draw"]
         self.mode_edit.available_modes = ["write", "annotate", "portal",
-                                          "name_thing", "enter_face", "enter_hat",
+                                          "name_thing", "enter_face", "enter_design",
                                           "password",
                                           "chat", "study", "play", "admin_enter"]
         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.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.switch_mode('waiting_for_server')
         self.keys = {
             'switch_to_chat': 't',
             'switch_to_play': 'p',
@@ -548,7 +519,7 @@ class TUI:
             'switch_to_admin_thing_protect': 'T',
             'flatten': 'F',
             'switch_to_enter_face': 'f',
-            'switch_to_enter_hat': 'H',
+            'switch_to_enter_design': 'D',
             'switch_to_take_thing': 'z',
             'switch_to_drop_thing': 'u',
             'teleport': 'p',
@@ -557,6 +528,7 @@ class TUI:
             'install': 'I',
             'wear': 'W',
             'spin': 'S',
+            'dance': 'T',
             'help': 'h',
             'toggle_map_mode': 'L',
             'toggle_tile_draw': 'm',
@@ -577,8 +549,6 @@ class TUI:
             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
@@ -586,61 +556,72 @@ class TUI:
         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):
-        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')
-        self.connect()
+        self.socket.connect()
+        self.update_on_connect()
 
     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
 
+    def socket_log(self, msg):
+        self.log('@ ' + 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:
@@ -655,13 +636,15 @@ class TUI:
         elif self.mode.name == 'admin_thing_protect':
             if hasattr(self.game.player.carrying, 'protection'):
                 self.input_ = self.game.player.carrying.protection
-        elif self.mode.name in {'enter_face', 'enter_hat'}:
+        elif self.mode.name == 'enter_face':
             start = self.ascii_draw_stage * 6
             end = (self.ascii_draw_stage + 1) * 6
-            if self.mode.name == 'enter_face':
-                self.input_ = self.game.player.face[start:end]
-            elif self.mode.name == 'enter_hat':
-                self.input_ = self.game.player.hat[start:end]
+            self.input_ = self.game.player.face[start:end]
+        elif self.mode.name == 'enter_design':
+            width = self.game.player.carrying.design[0].x
+            start = self.ascii_draw_stage * width
+            end = (self.ascii_draw_stage + 1) * width
+            self.input_ = self.game.player.carrying.design[1][start:end]
 
     def send_tile_control_command(self):
         self.send('SET_TILE_CONTROL %s %s' %
@@ -680,28 +663,32 @@ class TUI:
     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.log_msg('@ finished tile protection drawing.')
+            self.log('@ finished tile protection drawing.')
         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):
             return fail('not carrying anything commandable')
         if mode_name == 'name_thing' and not self.game.player.carrying:
-            return fail('not carrying anything to re-name')
+            return fail('not carrying anything to re-name', 'edit')
         if mode_name == 'admin_thing_protect' and not self.game.player.carrying:
             return fail('not carrying anything to protect')
         if mode_name == 'take_thing' and self.game.player.carrying:
             return fail('already carrying something')
         if mode_name == 'drop_thing' and not self.game.player.carrying:
             return fail('not carrying anything droppable')
-        if mode_name == 'enter_hat' and not hasattr(self.game.player, 'hat'):
-            return fail('not wearing hat to edit', 'edit')
+        if mode_name == 'enter_design' and\
+           (not self.game.player.carrying or
+            not hasattr(self.game.player.carrying, 'design')):
+            return fail('not carrying designable to edit', 'edit')
         if mode_name == 'admin_enter' and self.is_admin:
             mode_name = 'admin'
         self.mode = getattr(self, 'mode_' + mode_name)
@@ -716,14 +703,14 @@ class TUI:
         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:
-                self.log_msg('@ enter username')
+                self.log('@ enter username')
         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)
             }
@@ -757,28 +744,38 @@ class TUI:
             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.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.log_msg(str(i) + ': ' + self.selectables[i])
-        elif self.mode.name == 'enter_hat':
-            self.log_msg('legal characters: ' + self.game.players_hat_chars)
+                self.log(str(i) + ': ' + self.selectables[i])
+        elif self.mode.name == 'enter_design':
+            if self.game.player.carrying.type_ == 'Hat':
+                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))
+                self.log('@ Legal characters: ' + self.game.players_hat_chars)
+                self.log('@ (Eat cookies to extend the ASCII characters available for drawing.)')
+            else:
+                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':
-            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':
-            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):
-        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):
@@ -787,8 +784,9 @@ class TUI:
             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):
@@ -801,102 +799,321 @@ class TUI:
         else:
             for t in self.game.things:
                 if t.position == self.explorer:
-                    info_to_cache += 'THING: %s' % self.get_thing_info(t)
-                    protection = t.protection
-                    if protection == '.':
-                        protection = 'none'
-                    info_to_cache += ' / protection: %s\n' % protection
-                    if hasattr(t, 'hat'):
-                        info_to_cache += t.hat[0:6] + '\n'
-                        info_to_cache += t.hat[6:12] + '\n'
-                        info_to_cache += t.hat[12:18] + '\n'
-                    if hasattr(t, 'face'):
-                        info_to_cache += t.face[0:6] + '\n'
-                        info_to_cache += t.face[6:12] + '\n'
-                        info_to_cache += t.face[12:18] + '\n'
+                    info_to_cache += '%s' % self.get_thing_info(t, True)
             terrain_char = self.game.map_content[pos_i]
             terrain_desc = '?'
             if terrain_char in self.game.terrains:
                 terrain_desc = self.game.terrains[terrain_char]
-            info_to_cache += 'TERRAIN: "%s" / %s\n' % (terrain_char,
+            info_to_cache += 'TERRAIN: %s (%s' % (terrain_char,
                                                        terrain_desc)
             protection = self.game.map_control_content[pos_i]
-            if protection == '.':
-                protection = 'unprotected'
-            info_to_cache += 'PROTECTION: %s\n' % protection
+            if protection != '.':
+                info_to_cache += '/protection:%s' % protection
+            info_to_cache += ')\n'
             if self.explorer in self.game.portals:
                 info_to_cache += 'PORTAL: ' +\
                     self.game.portals[self.explorer] + '\n'
-            else:
-                info_to_cache += 'PORTAL: (none)\n'
             if self.explorer in self.game.annotations:
                 info_to_cache += 'ANNOTATION: ' +\
                     self.game.annotations[self.explorer]
         self.info_cached = info_to_cache
         return self.info_cached
 
-    def get_thing_info(self, t):
-        info = '%s / %s' %\
-            (t.type_, self.game.thing_types[t.type_])
+    def get_thing_info(self, t, detailed=False):
+        info = ''
+        if detailed:
+            info += '- '
+        info += self.game.thing_types[t.type_]
         if hasattr(t, 'thing_char'):
             info += t.thing_char
         if hasattr(t, 'name'):
-            info += ' (%s)' % t.name
+            info += ': %s' % t.name
+        info += ' (%s' % t.type_
         if hasattr(t, 'installed'):
-            info += ' / installed'
+            info += '/installed'
+        if t.type_ == 'Bottle':
+            if t.thing_char == '_':
+                info += '/empty'
+            elif t.thing_char == '~':
+                info += '/full'
+        if detailed:
+            protection = t.protection
+            if protection != '.':
+                info += '/protection:%s' % protection
+            info += ')\n'
+            if hasattr(t, 'hat') or hasattr(t, 'face'):
+                info += '----------\n'
+            if hasattr(t, 'hat'):
+                info += '| %s |\n' % t.hat[0:6]
+                info += '| %s |\n' % t.hat[6:12]
+                info += '| %s |\n' % t.hat[12:18]
+            if hasattr(t, 'face'):
+                info += '| %s |\n' % t.face[0:6]
+                info += '| %s |\n' % t.face[6:12]
+                info += '| %s |\n' % t.face[12:18]
+                info += '----------\n'
+            if hasattr(t, 'design'):
+                line_length = t.design[0].x
+                lines = []
+                for i in range(t.design[0].y):
+                    start = i * line_length
+                    end = (i + 1) * line_length
+                    lines += [t.design[1][start:end]]
+                info += '-' * (line_length + 4) + '\n'
+                for line in lines:
+                    info += '| %s |\n' % line
+                info += '-' * (line_length + 4) + '\n'
+        else:
+            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):
-            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)
@@ -907,456 +1124,242 @@ class TUI:
                     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_turn():
-            if not self.game.turn_complete:
-                return
-            safe_addstr(0, self.window_width, 'TURN: ' + str(self.game.turn))
-
-        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
-            t_char = ' '
-            if hasattr(t, 'thing_char'):
-                t_char = t.thing_char
-            def draw_body_part(body_part, end_y):
-                safe_addstr(end_y - 4, start_x, ' _[ @' + t_char + ' ]_ ')
-                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 - 2)
-            if hasattr(t, 'hat'):
-                draw_body_part(t.hat, self.size.y - 5)
-            safe_addstr(self.size.y - 1, start_x, '|        |')
-
-        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_turn()
-                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):
-                    self.log_msg('? invalid index, aborted')
+                    self.log('? invalid index, aborted')
                 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')
 
-        def enter_ascii_art(command):
-            if len(self.input_) != 6:
-                self.log_msg('? wrong input length, must be 6; try again')
+        def enter_ascii_art(command, height, width,
+                            with_pw=False, with_size=False):
+            if with_size and self.ascii_draw_stage == 0:
+                width = len(self.input_)
+                if width > 36:
+                    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]
+                if width != old_size.x:
+                    # TODO: save remaining design?
+                    self.game.player.carrying.design[1] = ''
+                    self.game.player.carrying.design[0] = YX(old_size.y, width)
+            elif len(self.input_) > width:
+                self.log('? input too long, '
+                             'must be max %s; try again' % width)
                 return
-            self.log_msg('  ' + self.input_)
-            self.full_ascii_draw += self.input_
+            self.log('  ' + self.input_)
+            if with_size and self.input_ in {'', ' '}\
+               and self.ascii_draw_stage > 0:
+                height = self.ascii_draw_stage
+            else:
+                if with_size:
+                    height = self.ascii_draw_stage + 2
+                if len(self.input_) < width:
+                    self.input_ += ' ' * (width - len(self.input_))
+                self.full_ascii_draw += self.input_
+            if with_size:
+                old_size = self.game.player.carrying.design[0]
+                self.game.player.carrying.design[0] = YX(height, old_size.x)
             self.ascii_draw_stage += 1
-            if self.ascii_draw_stage < 3:
+            if self.ascii_draw_stage < height:
                 self.restore_input_values()
             else:
-                self.send('%s %s' % (command, quote(self.full_ascii_draw)))
+                if with_pw and with_size:
+                    self.send('%s_SIZE %s %s' % (command, YX(height, width),
+                                                 quote(self.password)))
+                if with_pw:
+                    self.send('%s %s %s' % (command, quote(self.full_ascii_draw),
+                                            quote(self.password)))
+                else:
+                    self.send('%s %s' % (command, quote(self.full_ascii_draw)))
                 self.full_ascii_draw = ""
                 self.ascii_draw_stage = 0
                 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',
-        }
-
-        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',
-        }
-
-        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_ = ''
-        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)
-            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')
-            elif self.mode.name == 'enter_hat' and key == '\n':
-                enter_ascii_art('PLAYER_HAT')
-            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')
-            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')
-            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:
-                        self.log_msg('? unknown command')
+                        self.log('? need login name')
                 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['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 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]
-TUI(host)
+RogueChatTUI(host)