home · contact · privacy
Refactor parser code.
[plomrogue2] / rogue_chat_curses.py
index f75877aff1af88ca76cdf0e228c58cac01a69f1c..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': {
@@ -29,35 +30,40 @@ mode_helps = {
     'name_thing': {
         'short': 'name thing',
         'intro': '',
-        'long': 'Give name to/change name of thing here.'
+        'long': 'Give name to/change name of carried thing.'
     },
     'command_thing': {
-        'short': 'command thing',
+        'short': 'command',
         'intro': '',
         'long': 'Enter a command to the thing you carry.  Enter nothing to return to play mode.'
     },
     'take_thing': {
-        'short': 'take thing',
+        'short': 'take',
         'intro': 'Pick up a thing in reach by entering its index number.  Enter nothing to abort.',
         'long': 'You see a list of things which you could pick up.  Enter the target thing\'s index, or, to leave, nothing.'
     },
     'drop_thing': {
-        'short': 'drop thing',
+        'short': 'drop',
         'intro': 'Enter number of direction to which you want to drop thing.',
         'long': 'Drop currently carried thing by entering the target direction index.  Enter nothing to return to play mode..'
     },
     'admin_thing_protect': {
         'short': 'change thing protection',
         'intro': '@ enter thing protection character:',
-        'long': 'Change protection character for thing here.'
+        'long': 'Change protection character for carried thing.'
     },
     'enter_face': {
-        'short': 'enter your face',
-        'intro': '@ enter face line (enter nothing to abort):',
+        'short': 'edit face',
+        '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_design': {
+        'short': 'edit design',
+        'intro': '@ enter design:',
+        'long': 'Enter design for carried thing as ASCII art.'
+    },
     'write': {
-        'short': 'change terrain',
+        'short': 'edit tile',
         'intro': '',
         'long': 'This mode allows you to change the map tile you currently stand on (if your world editing password authorizes you so).  Just enter any printable ASCII character to imprint it on the ground below you.'
     },
@@ -128,59 +134,20 @@ 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.annotations = {}
-    game.turn = n
-    game.things = []
-    game.portals = {}
     game.turn_complete = False
-    game.fov = ''
 cmd_TURN.argtypes = 'int:nonneg'
 
+def cmd_OTHER_WIPE(game):
+    game.portals_new = {}
+    game.annotations_new = {}
+    game.things_new = []
+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):
@@ -200,15 +167,24 @@ def cmd_CHAT(game, msg):
     game.tui.do_refresh = True
 cmd_CHAT.argtypes = 'string'
 
+def cmd_CHATFACE(game, thing_id):
+    game.tui.draw_face = thing_id
+    game.tui.do_refresh = True
+cmd_CHATFACE.argtypes = 'int:pos'
+
 def cmd_PLAYER_ID(game, player_id):
     game.player_id = player_id
 cmd_PLAYER_ID.argtypes = 'int:nonneg'
 
+def cmd_PLAYERS_HAT_CHARS(game, hat_chars):
+    game.players_hat_chars_new = hat_chars
+cmd_PLAYERS_HAT_CHARS.argtypes = 'string'
+
 def cmd_THING(game, yx, thing_type, protection, thing_id, portable, commandable):
-    t = game.get_thing(thing_id)
+    t = game.get_thing_temp(thing_id)
     if not t:
         t = ThingBase(game, thing_id)
-        game.things += [t]
+        game.things_new += [t]
     t.position = yx
     t.type_ = thing_type
     t.protection = protection
@@ -217,37 +193,42 @@ def cmd_THING(game, yx, thing_type, protection, thing_id, portable, commandable)
 cmd_THING.argtypes = 'yx_tuple:nonneg string:thing_type char int:nonneg bool bool'
 
 def cmd_THING_NAME(game, thing_id, name):
-    t = game.get_thing(thing_id)
+    t = game.get_thing_temp(thing_id)
     t.name = name
 cmd_THING_NAME.argtypes = 'int:pos string'
 
 def cmd_THING_FACE(game, thing_id, face):
-    t = game.get_thing(thing_id)
+    t = game.get_thing_temp(thing_id)
     t.face = face
 cmd_THING_FACE.argtypes = 'int:pos string'
 
 def cmd_THING_HAT(game, thing_id, hat):
-    t = game.get_thing(thing_id)
+    t = game.get_thing_temp(thing_id)
     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(thing_id)
+    t = game.get_thing_temp(thing_id)
     t.thing_char = c
 cmd_THING_CHAR.argtypes = 'int:pos char'
 
 def cmd_MAP(game, geometry, size, content):
     map_geometry_class = globals()['MapGeometry' + geometry]
-    game.map_geometry = map_geometry_class(size)
-    game.map_content = content
-    if type(game.map_geometry) == MapGeometrySquare:
+    game.map_geometry_new = map_geometry_class(size)
+    game.map_content_new = content
+    if type(game.map_geometry_new) == MapGeometrySquare:
         game.tui.movement_keys = {
             game.tui.keys['square_move_up']: 'UP',
             game.tui.keys['square_move_left']: 'LEFT',
             game.tui.keys['square_move_down']: 'DOWN',
             game.tui.keys['square_move_right']: 'RIGHT',
         }
-    elif type(game.map_geometry) == MapGeometryHex:
+    elif type(game.map_geometry_new) == MapGeometryHex:
         game.tui.movement_keys = {
             game.tui.keys['hex_move_upleft']: 'UPLEFT',
             game.tui.keys['hex_move_upright']: 'UPRIGHT',
@@ -259,24 +240,34 @@ def cmd_MAP(game, geometry, size, content):
 cmd_MAP.argtypes = 'string:map_geometry yx_tuple:pos string'
 
 def cmd_FOV(game, content):
-    game.fov = content
+    game.fov_new = content
 cmd_FOV.argtypes = 'string'
 
 def cmd_MAP_CONTROL(game, content):
-    game.map_control_content = content
+    game.map_control_content_new = content
 cmd_MAP_CONTROL.argtypes = 'string'
 
 def cmd_GAME_STATE_COMPLETE(game):
-    game.turn_complete = True
     game.tui.do_refresh = True
     game.tui.info_cached = None
+    game.things = game.things_new
+    game.portals = game.portals_new
+    game.annotations = game.annotations_new
+    game.fov = game.fov_new
+    game.map_geometry = game.map_geometry_new
+    game.map_content = game.map_content_new
+    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')
 cmd_GAME_STATE_COMPLETE.argtypes = ''
 
 def cmd_PORTAL(game, position, msg):
-    game.portals[position] = msg
+    game.portals_new[position] = msg
 cmd_PORTAL.argtypes = 'yx_tuple:nonneg string'
 
 def cmd_PLAY_ERROR(game, msg):
@@ -296,7 +287,7 @@ def cmd_ARGUMENT_ERROR(game, msg):
 cmd_ARGUMENT_ERROR.argtypes = 'string'
 
 def cmd_ANNOTATION(game, position, msg):
-    game.annotations[position] = msg
+    game.annotations_new[position] = msg
     if game.tui.mode.shows_info:
         game.tui.do_refresh = True
 cmd_ANNOTATION.argtypes = 'yx_tuple:nonneg string'
@@ -311,14 +302,15 @@ 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):
-    game.get_thing(thing_id).installed = True
+    game.get_thing_temp(thing_id).installed = True
 cmd_THING_INSTALLED.argtypes = 'int:pos'
 
 def cmd_THING_CARRYING(game, thing_id, carried_id):
-    game.get_thing(thing_id).carrying = game.get_thing(carried_id)
+    game.get_thing_temp(thing_id).carrying = game.get_thing_temp(carried_id)
 cmd_THING_CARRYING.argtypes = 'int:pos int:pos'
 
 def cmd_TERRAIN(game, terrain_char, terrain_desc):
@@ -337,10 +329,16 @@ 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 = {}
     thing_types = {}
+    things_new = []
 
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
@@ -348,15 +346,18 @@ class Game(GameBase):
         self.register_command(cmd_ADMIN_OK)
         self.register_command(cmd_PONG)
         self.register_command(cmd_CHAT)
+        self.register_command(cmd_CHATFACE)
         self.register_command(cmd_REPLY)
         self.register_command(cmd_PLAYER_ID)
         self.register_command(cmd_TURN)
+        self.register_command(cmd_OTHER_WIPE)
         self.register_command(cmd_THING)
         self.register_command(cmd_THING_TYPE)
         self.register_command(cmd_THING_NAME)
         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)
@@ -365,6 +366,7 @@ class Game(GameBase):
         self.register_command(cmd_PORTAL)
         self.register_command(cmd_ANNOTATION)
         self.register_command(cmd_GAME_STATE_COMPLETE)
+        self.register_command(cmd_PLAYERS_HAT_CHARS)
         self.register_command(cmd_ARGUMENT_ERROR)
         self.register_command(cmd_GAME_ERROR)
         self.register_command(cmd_PLAY_ERROR)
@@ -372,19 +374,24 @@ 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
         self.annotations = {}
+        self.annotations_new = {}
         self.portals = {}
+        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
@@ -392,6 +399,12 @@ class Game(GameBase):
         f.argtypes = self.commands[command_name].argtypes
         return f
 
+    def get_thing_temp(self, id_):
+        for thing in self.things_new:
+            if id_ == thing.id_:
+                return thing
+        return None
+
 class Mode:
 
     def __init__(self, name, has_input_prompt=False, shows_info=False,
@@ -431,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')
@@ -455,42 +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_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",
-                                          "password", "chat", "study", "play",
-                                          "admin_enter", "enter_face"]
-        self.mode_edit.available_actions = ["move", "flatten", "toggle_map_mode"]
+        self.mode_edit.available_modes = ["write", "annotate", "portal",
+                                          "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',
@@ -508,6 +519,7 @@ class TUI:
             'switch_to_admin_thing_protect': 'T',
             'flatten': 'F',
             'switch_to_enter_face': 'f',
+            'switch_to_enter_design': 'D',
             'switch_to_take_thing': 'z',
             'switch_to_drop_thing': 'u',
             'teleport': 'p',
@@ -516,6 +528,7 @@ class TUI:
             'install': 'I',
             'wear': 'W',
             'spin': 'S',
+            'dance': 'T',
             'help': 'h',
             'toggle_map_mode': 'L',
             'toggle_tile_draw': 'm',
@@ -536,68 +549,79 @@ 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
         self.map_lines = []
+        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:
@@ -607,11 +631,20 @@ class TUI:
         elif self.mode.name == 'password':
             self.input_ = self.password
         elif self.mode.name == 'name_thing':
-            if hasattr(self.thing_selected, 'name'):
-                self.input_ = self.thing_selected.name
+            if hasattr(self.game.player.carrying, 'name'):
+                self.input_ = self.game.player.carrying.name
         elif self.mode.name == 'admin_thing_protect':
-            if hasattr(self.thing_selected, 'protection'):
-                self.input_ = self.thing_selected.protection
+            if hasattr(self.game.player.carrying, 'protection'):
+                self.input_ = self.game.player.carrying.protection
+        elif self.mode.name == 'enter_face':
+            start = self.ascii_draw_stage * 6
+            end = (self.ascii_draw_stage + 1) * 6
+            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' %
@@ -629,35 +662,35 @@ class TUI:
 
     def switch_mode(self, mode_name):
 
-        def fail(msg):
-            self.log_msg('? ' + msg)
+        def fail(msg, return_mode='play'):
+            self.log('? ' + msg)
             self.flash = True
-            self.switch_mode('play')
+            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', '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_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'
-        elif mode_name in {'name_thing', 'admin_thing_protect'}:
-            thing = None
-            for t in [t for t in self.game.things
-                      if t.position == self.game.player.position
-                      and t.id_ != self.game.player.id_]:
-                thing = t
-                break
-            if not thing:
-                return fail('not standing over thing')
-            else:
-                self.thing_selected = thing
         self.mode = getattr(self, 'mode_' + mode_name)
         if self.mode.name in {'control_tile_draw', 'control_tile_type',
                               'control_pw_type'}:
@@ -670,52 +703,79 @@ 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:')
-            select_range = [self.game.player.position,
-                            self.game.player.position + YX(0,-1),
-                            self.game.player.position + YX(0, 1),
-                            self.game.player.position + YX(-1, 0),
-                            self.game.player.position + YX(1, 0)]
-            if type(self.game.map_geometry) == MapGeometryHex:
+            self.log('Portable things in reach for pick-up:')
+            directed_moves = {
+                'HERE': YX(0, 0), 'LEFT': YX(0, -1), 'RIGHT': YX(0, 1)
+            }
+            if type(self.game.map_geometry) == MapGeometrySquare:
+                directed_moves['UP'] = YX(-1, 0)
+                directed_moves['DOWN'] = YX(1, 0)
+            elif type(self.game.map_geometry) == MapGeometryHex:
                 if self.game.player.position.y % 2:
-                    select_range += [self.game.player.position + YX(-1, 1),
-                                     self.game.player.position + YX(1, 1)]
+                    directed_moves['UPLEFT'] = YX(-1, 0)
+                    directed_moves['UPRIGHT'] = YX(-1, 1)
+                    directed_moves['DOWNLEFT'] = YX(1, 0)
+                    directed_moves['DOWNRIGHT'] = YX(1, 1)
                 else:
-                    select_range += [self.game.player.position + YX(-1, -1),
-                                     self.game.player.position + YX(1, -1)]
-            self.selectables = [t.id_ for t in self.game.things
-                                if t.portable and t.position in select_range]
+                    directed_moves['UPLEFT'] = YX(-1, -1)
+                    directed_moves['UPRIGHT'] = YX(-1, 0)
+                    directed_moves['DOWNLEFT'] = YX(1, -1)
+                    directed_moves['DOWNRIGHT'] = YX(1, 0)
+            select_range = {}
+            for direction in directed_moves:
+                move = directed_moves[direction]
+                select_range[direction] = self.game.player.position + move
+            self.selectables = []
+            directions = []
+            for direction in select_range:
+                for t in [t for t in self.game.things
+                          if t.portable and t.position == select_range[direction]]:
+                    self.selectables += [t.id_]
+                    directions += [direction]
             if len(self.selectables) == 0:
                 return fail('nothing to pick-up')
             else:
                 for i in range(len(self.selectables)):
                     t = self.game.get_thing(self.selectables[i])
-                    self.log_msg(str(i) + ': ' + self.get_thing_info(t))
+                    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])
+                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):
@@ -724,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):
@@ -738,101 +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)
@@ -843,418 +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_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()
-
         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')
 
-        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)
-            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_ = ""
+        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('  ' + 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 < height:
                 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':
-                if len(self.input_) != 18:
-                    self.log_msg('? wrong input length, aborting')
+            else:
+                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('PLAYER_FACE %s' % quote(self.input_))
+                    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')
-            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 %s' % (self.thing_selected.id_,
-                                                          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 %s' % (self.thing_selected.id_,
-                                                   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['install'] and task_action_on('install'):
-                    self.send('TASK:INSTALL')
-                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['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)