home · contact · privacy
Refactor client curses code.
[plomrogue2] / rogue_chat_curses.py
index 72fc0a7dddf94ed5ba1baff57c68f723e20d0b64..11fe94868c72ee8891877eec4a487c123ac41368 100755 (executable)
@@ -1,15 +1,16 @@
 #!/usr/bin/env python3
 import curses
 #!/usr/bin/env python3
 import curses
-import queue
-import threading
-import time
 import sys
 from plomrogue.game import GameBase
 from plomrogue.parser import Parser
 from plomrogue.mapping import YX, MapGeometrySquare, MapGeometryHex
 from plomrogue.things import ThingBase
 from plomrogue.misc import quote
 import sys
 from plomrogue.game import GameBase
 from plomrogue.parser import Parser
 from plomrogue.mapping import YX, MapGeometrySquare, MapGeometryHex
 from plomrogue.things import ThingBase
 from plomrogue.misc import quote
-from plomrogue.errors import BrokenSocketConnection, ArgError
+from plomrogue.errors import ArgError
+from plomrogue_client.socket import ClientSocket
+from plomrogue_client.tui import msg_into_lines_of_width, CursesScreen
+
+
 
 mode_helps = {
     'play': {
 
 mode_helps = {
     'play': {
@@ -53,13 +54,13 @@ mode_helps = {
     },
     'enter_face': {
         'short': 'edit face',
     },
     '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..'
     },
         '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',
     },
     'write': {
         'short': 'edit tile',
@@ -133,46 +134,6 @@ mode_helps = {
     }
 }
 
     }
 }
 
-from ws4py.client import WebSocketBaseClient
-class WebSocketClient(WebSocketBaseClient):
-
-    def __init__(self, recv_handler, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-        self.recv_handler = recv_handler
-        self.connect()
-
-    def received_message(self, message):
-        if message.is_text:
-            message = str(message)
-            self.recv_handler(message)
-
-    @property
-    def plom_closed(self):
-        return self.client_terminated
-
-from plomrogue.io_tcp import PlomSocket
-class PlomSocketClient(PlomSocket):
-
-    def __init__(self, recv_handler, url):
-        import socket
-        self.recv_handler = recv_handler
-        host, port = url.split(':')
-        super().__init__(socket.create_connection((host, port)))
-
-    def close(self):
-        self.socket.close()
-
-    def run(self):
-        import ssl
-        try:
-            for msg in self.recv():
-                if msg == 'NEED_SSL':
-                    self.socket = ssl.wrap_socket(self.socket)
-                    continue
-                self.recv_handler(msg)
-        except BrokenSocketConnection:
-            pass  # we assume socket will be known as dead by now
-
 def cmd_TURN(game, n):
     game.turn_complete = False
 cmd_TURN.argtypes = 'int:nonneg'
 def cmd_TURN(game, n):
     game.turn_complete = False
 cmd_TURN.argtypes = 'int:nonneg'
@@ -187,8 +148,6 @@ def cmd_LOGIN_OK(game):
     game.tui.switch_mode('post_login_wait')
     game.tui.send('GET_GAMESTATE')
     game.tui.log_msg('@ welcome!')
     game.tui.switch_mode('post_login_wait')
     game.tui.send('GET_GAMESTATE')
     game.tui.log_msg('@ welcome!')
-    game.tui.log_msg('@ hint: see top of terminal for how to get help.')
-    game.tui.log_msg('@ hint: enter study mode to understand your environment.')
 cmd_LOGIN_OK.argtypes = ''
 
 def cmd_ADMIN_OK(game):
 cmd_LOGIN_OK.argtypes = ''
 
 def cmd_ADMIN_OK(game):
@@ -248,6 +207,11 @@ def cmd_THING_HAT(game, thing_id, hat):
     t.hat = hat
 cmd_THING_HAT.argtypes = 'int:pos string'
 
     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
 def cmd_THING_CHAR(game, thing_id, c):
     t = game.get_thing_temp(thing_id)
     t.thing_char = c
@@ -392,6 +356,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_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)
         self.register_command(cmd_THING_CARRYING)
         self.register_command(cmd_THING_INSTALLED)
         self.register_command(cmd_TERRAIN)
@@ -501,7 +466,7 @@ 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_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
 
     is_admin = False
     tile_draw = False
 
@@ -523,19 +488,18 @@ class TUI:
         self.mode_control_tile_draw.available_actions = ["move_explorer",
                                                          "toggle_tile_draw"]
         self.mode_edit.available_modes = ["write", "annotate", "portal",
         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
                                           "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.game = Game()
         self.game.tui = self
         self.parser = Parser(self.game)
         self.log = []
         self.do_refresh = True
-        self.queue = queue.Queue()
         self.login_name = None
         self.map_mode = 'terrain + things'
         self.password = 'foo'
         self.login_name = None
         self.map_mode = 'terrain + things'
         self.password = 'foo'
@@ -557,7 +521,7 @@ class TUI:
             'switch_to_admin_thing_protect': 'T',
             'flatten': 'F',
             'switch_to_enter_face': 'f',
             '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',
             'switch_to_take_thing': 'z',
             'switch_to_drop_thing': 'u',
             'teleport': 'p',
@@ -587,8 +551,6 @@ class TUI:
             for k in keys_conf:
                 self.keys[k] = keys_conf[k]
         self.show_help = False
             for k in keys_conf:
                 self.keys[k] = keys_conf[k]
         self.show_help = False
-        self.disconnected = True
-        self.force_instant_connect = True
         self.input_lines = []
         self.fov = ''
         self.flash = False
         self.input_lines = []
         self.fov = ''
         self.flash = False
@@ -596,57 +558,33 @@ class TUI:
         self.ascii_draw_stage = 0
         self.full_ascii_draw = ''
         self.offset = YX(0,0)
         self.ascii_draw_stage = 0
         self.full_ascii_draw = ''
         self.offset = YX(0,0)
-        curses.wrapper(self.loop)
+        self.screen = CursesScreen()
+        self.screen.wrap_loop(self.loop)
 
 
-    def connect(self):
-
-        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.socket.send('TASKS')
+        self.socket.send('TERRAINS')
+        self.socket.send('THING_TYPES')
+        self.switch_mode('login')
 
     def reconnect(self):
 
     def reconnect(self):
+        import time
         self.log_msg('@ attempting reconnect')
         self.log_msg('@ attempting reconnect')
-        self.send('QUIT')
+        self.socket.send('QUIT')
         # necessitated by some strange SSL race conditions with ws4py
         time.sleep(0.1)  # FIXME find out why exactly necessary
         self.switch_mode('waiting_for_server')
         # necessitated by some strange SSL race conditions with ws4py
         time.sleep(0.1)  # FIXME find out why exactly necessary
         self.switch_mode('waiting_for_server')
-        self.connect()
+        self.socket.connect()
+        self.update_on_connect()
 
     def send(self, msg):
 
     def send(self, msg):
-        try:
-            if hasattr(self.socket, 'plom_closed') and self.socket.plom_closed:
-                raise BrokenSocketConnection
-            self.socket.send(msg)
-        except (BrokenPipeError, BrokenSocketConnection):
-            self.log_msg('@ server disconnected :(')
-            self.disconnected = True
-            self.force_instant_connect = True
+        self.socket.send(msg)
+        if self.socket.disconnected:
             self.do_refresh = True
 
             self.do_refresh = True
 
+    def socket_log(self, msg):
+        self.log_msg('@ ' + msg)
+
     def log_msg(self, msg):
         self.log += [msg]
         if len(self.log) > 100:
     def log_msg(self, msg):
         self.log += [msg]
         if len(self.log) > 100:
@@ -665,13 +603,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 == '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
             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' %
 
     def send_tile_control_command(self):
         self.send('SET_TILE_CONTROL %s %s' %
@@ -698,6 +638,8 @@ class TUI:
             self.log_msg('@ finished tile protection drawing.')
         self.draw_face = False
         self.tile_draw = False
             self.log_msg('@ 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):
         if mode_name == 'command_thing' and\
            (not self.game.player.carrying or
             not self.game.player.carrying.commandable):
@@ -710,8 +652,10 @@ class TUI:
             return fail('already carrying something')
         if mode_name == 'drop_thing' and not self.game.player.carrying:
             return fail('not carrying anything droppable')
             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)
         if mode_name == 'admin_enter' and self.is_admin:
             mode_name = 'admin'
         self.mode = getattr(self, 'mode_' + mode_name)
@@ -775,8 +719,17 @@ class TUI:
                 ['HERE'] + list(self.game.tui.movement_keys.values())
             for i in range(len(self.selectables)):
                 self.log_msg(str(i) + ': ' + self.selectables[i])
                 ['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)
+        elif self.mode.name == 'enter_design':
+            if self.game.player.carrying.type_ == 'Hat':
+                self.log_msg('@ The design you enter must be %s lines of max %s '
+                             'characters width each'
+                             % (self.game.player.carrying.design[0].y,
+                                self.game.player.carrying.design[0].x))
+                self.log_msg('@ Legal characters: ' + self.game.players_hat_chars)
+                self.log_msg('@ (Eat cookies to extend the ASCII characters available for drawing.)')
+            else:
+                self.log_msg('@ Width of first line determines maximum width for remaining design')
+                self.log_msg('@ Finish design by entering an empty line (multiple space characters do not count as empty)')
         elif self.mode.name == 'command_thing':
             self.send('TASK:COMMAND ' + quote('HELP'))
         elif self.mode.name == 'control_pw_pw':
         elif self.mode.name == 'command_thing':
             self.send('TASK:COMMAND ' + quote('HELP'))
         elif self.mode.name == 'control_pw_pw':
@@ -787,8 +740,9 @@ class TUI:
         self.restore_input_values()
 
     def set_default_colors(self):
         self.restore_input_values()
 
     def set_default_colors(self):
-        curses.init_color(1, 1000, 1000, 1000)
-        curses.init_color(2, 0, 0, 0)
+        if curses.can_change_color():
+            curses.init_color(7, 1000, 1000, 1000)
+            curses.init_color(0, 0, 0, 0)
         self.do_refresh = True
 
     def set_random_colors(self):
         self.do_refresh = True
 
     def set_random_colors(self):
@@ -797,8 +751,9 @@ class TUI:
             import random
             return int(offset + random.random()*375)
 
             import random
             return int(offset + random.random()*375)
 
-        curses.init_color(1, rand(625), rand(625), rand(625))
-        curses.init_color(2, rand(0), rand(0), rand(0))
+        if curses.can_change_color():
+            curses.init_color(7, rand(625), rand(625), rand(625))
+            curses.init_color(0, rand(0), rand(0), rand(0))
         self.do_refresh = True
 
     def get_info(self):
         self.do_refresh = True
 
     def get_info(self):
@@ -811,64 +766,78 @@ class TUI:
         else:
             for t in self.game.things:
                 if t.position == self.explorer:
         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]
             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]
                                                        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'
             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
 
             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'):
         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'):
         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
 
         return info
 
-    def loop(self, stdscr):
-        import datetime
+    def loop(self):
 
         def safe_addstr(y, x, line):
 
         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))
+            self.screen.safe_addstr(y, x, line, curses.color_pair(1))
 
         def handle_input(msg):
             command, args = self.parser.parse(msg)
 
         def handle_input(msg):
             command, args = self.parser.parse(msg)
@@ -877,28 +846,10 @@ class TUI:
         def task_action_on(action):
             return action_tasks[action] in self.game.tasks
 
         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():
         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)
+            self.screen.reset_size()
+            self.left_window_width = min(52, int(self.screen.size.x / 2))
+            self.right_window_width = self.screen.size.x - self.left_window_width
 
         def recalc_input_lines():
             if not self.mode.has_input_prompt:
 
         def recalc_input_lines():
             if not self.mode.has_input_prompt:
@@ -906,7 +857,7 @@ class TUI:
             else:
                 self.input_lines = msg_into_lines_of_width(input_prompt
                                                            + self.input_ + '█',
             else:
                 self.input_lines = msg_into_lines_of_width(input_prompt
                                                            + self.input_ + '█',
-                                                           self.window_width)
+                                                           self.right_window_width)
 
         def move_explorer(direction):
             target = self.game.map_geometry.move_yx(self.explorer, direction)
 
         def move_explorer(direction):
             target = self.game.map_geometry.move_yx(self.explorer, direction)
@@ -921,41 +872,41 @@ class TUI:
         def draw_history():
             lines = []
             for line in self.log:
         def draw_history():
             lines = []
             for line in self.log:
-                lines += msg_into_lines_of_width(line, self.window_width)
+                lines += msg_into_lines_of_width(line, self.right_window_width)
             lines.reverse()
             height_header = 2
             lines.reverse()
             height_header = 2
-            max_y = self.size.y - len(self.input_lines)
+            max_y = self.screen.size.y - len(self.input_lines)
             for i in range(len(lines)):
                 if (i >= max_y - height_header):
                     break
             for i in range(len(lines)):
                 if (i >= max_y - height_header):
                     break
-                safe_addstr(max_y - i - 1, self.window_width, lines[i])
+                safe_addstr(max_y - i - 1, self.left_window_width, lines[i])
 
         def draw_info():
             info = 'MAP VIEW: %s\n%s' % (self.map_mode, self.get_info())
 
         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)
+            lines = msg_into_lines_of_width(info, self.right_window_width)
             height_header = 2
             for i in range(len(lines)):
                 y = height_header + i
             height_header = 2
             for i in range(len(lines)):
                 y = height_header + i
-                if y >= self.size.y - len(self.input_lines):
+                if y >= self.screen.size.y - len(self.input_lines):
                     break
                     break
-                safe_addstr(y, self.window_width, lines[i])
+                safe_addstr(y, self.left_window_width, lines[i])
 
         def draw_input():
 
         def draw_input():
-            y = self.size.y - len(self.input_lines)
+            y = self.screen.size.y - len(self.input_lines)
             for i in range(len(self.input_lines)):
             for i in range(len(self.input_lines)):
-                safe_addstr(y, self.window_width, self.input_lines[i])
+                safe_addstr(y, self.left_window_width, self.input_lines[i])
                 y += 1
 
         def draw_stats():
             stats = 'ENERGY: %s BLADDER: %s' % (self.game.energy,
                                                 self.game.bladder_pressure)
                 y += 1
 
         def draw_stats():
             stats = 'ENERGY: %s BLADDER: %s' % (self.game.energy,
                                                 self.game.bladder_pressure)
-            safe_addstr(0, self.window_width, stats)
+            safe_addstr(0, self.left_window_width, stats)
 
         def draw_mode():
             help = "hit [%s] for help" % self.keys['help']
             if self.mode.has_input_prompt:
                 help = "enter /help for help"
 
         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,
+            safe_addstr(1, self.left_window_width,
                         'MODE: %s – %s' % (self.mode.short_desc, help))
 
         def draw_map():
                         'MODE: %s – %s' % (self.mode.short_desc, help))
 
         def draw_map():
@@ -1011,8 +962,8 @@ class TUI:
                 else:
                     for line in map_lines_split:
                         self.map_lines += [''.join(line)]
                 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))
+                window_center = YX(int(self.screen.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 = self.game.player.position
                 if self.mode.shows_info or self.mode.name == 'control_tile_draw':
                     center = self.explorer
@@ -1024,34 +975,52 @@ class TUI:
             term_x = max(0, -self.offset.x)
             map_y = max(0, self.offset.y)
             map_x = max(0, self.offset.x)
             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]
+            while term_y < self.screen.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]
                 safe_addstr(term_y, term_x, to_draw)
                 term_y += 1
                 map_y += 1
 
                 safe_addstr(term_y, term_x, to_draw)
                 term_y += 1
                 map_y += 1
 
+        def draw_names():
+            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.screen.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] + '…'
+                safe_addstr(y, 0, '@%s:%s' % (t.thing_char, name))
+                y += 1
+                if y >= self.screen.size.y:
+                    break
+
         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
 
         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
+            start_x = self.left_window_width - 10
             def draw_body_part(body_part, end_y):
             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 - 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'):
                 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)
+                draw_body_part(t.face, self.screen.size.y - 3)
             if hasattr(t, 'hat'):
             if hasattr(t, 'hat'):
-                draw_body_part(t.hat, self.size.y - 5)
-            safe_addstr(self.size.y - 1, start_x, '|        |')
+                draw_body_part(t.hat, self.screen.size.y - 6)
+            safe_addstr(self.screen.size.y - 2, start_x, '----------')
+            name = t.name[:]
+            if len(name) > 7:
+                name = name[:6 - 1] + '…'
+            safe_addstr(self.screen.size.y - 1, start_x,
+                        '@%s:%s' % (t.thing_char, name))
 
         def draw_help():
             content = "%s help\n\n%s\n\n" % (self.mode.short_desc,
 
         def draw_help():
             content = "%s help\n\n%s\n\n" % (self.mode.short_desc,
@@ -1071,23 +1040,23 @@ class TUI:
                     content += '[%s] – %s\n' % (key, action_descriptions[action])
                 content += '\n'
             content += self.mode.list_available_modes(self)
                     content += '[%s] – %s\n' % (key, action_descriptions[action])
                 content += '\n'
             content += self.mode.list_available_modes(self)
-            for i in range(self.size.y):
+            for i in range(self.screen.size.y):
                 safe_addstr(i,
                 safe_addstr(i,
-                            self.window_width * (not self.mode.has_input_prompt),
-                            ' ' * self.window_width)
+                            self.left_window_width * (not self.mode.has_input_prompt),
+                            ' ' * self.left_window_width)
             lines = []
             for line in content.split('\n'):
             lines = []
             for line in content.split('\n'):
-                lines += msg_into_lines_of_width(line, self.window_width)
+                lines += msg_into_lines_of_width(line, self.right_window_width)
             for i in range(len(lines)):
             for i in range(len(lines)):
-                if i >= self.size.y:
+                if i >= self.screen.size.y:
                     break
                 safe_addstr(i,
                     break
                 safe_addstr(i,
-                            self.window_width * (not self.mode.has_input_prompt),
+                            self.left_window_width * (not self.mode.has_input_prompt),
                             lines[i])
 
         def draw_screen():
                             lines[i])
 
         def draw_screen():
-            stdscr.clear()
-            stdscr.bkgd(' ', curses.color_pair(1))
+            self.screen.stdscr.clear()
+            self.screen.stdscr.bkgd(' ', curses.color_pair(1))
             recalc_input_lines()
             if self.mode.has_input_prompt:
                 draw_input()
             recalc_input_lines()
             if self.mode.has_input_prompt:
                 draw_input()
@@ -1101,8 +1070,10 @@ class TUI:
                 draw_map()
             if self.show_help:
                 draw_help()
                 draw_map()
             if self.show_help:
                 draw_help()
-            if self.draw_face and self.mode.name in {'chat', 'play'}:
-                draw_face_popup()
+            if self.mode.name in {'chat', 'play'}:
+                draw_names()
+                if self.draw_face:
+                    draw_face_popup()
 
         def pick_selectable(task_name):
             try:
 
         def pick_selectable(task_name):
             try:
@@ -1116,17 +1087,48 @@ class TUI:
             self.input_ = ''
             self.switch_mode('play')
 
             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_msg('? 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_msg('? input too long, '
+                             'must be max %s; try again' % width)
                 return
             self.log_msg('  ' + self.input_)
                 return
             self.log_msg('  ' + self.input_)
-            self.full_ascii_draw += 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
             self.ascii_draw_stage += 1
-            if self.ascii_draw_stage < 3:
+            if self.ascii_draw_stage < height:
                 self.restore_input_values()
             else:
                 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.full_ascii_draw = ""
                 self.ascii_draw_stage = 0
                 self.input_ = ""
@@ -1162,43 +1164,33 @@ class TUI:
             'dance': 'DANCE',
         }
 
             'dance': 'DANCE',
         }
 
-        curses.curs_set(False)  # hide cursor
         curses.start_color()
         self.set_default_colors()
         curses.start_color()
         self.set_default_colors()
-        curses.init_pair(1, 1, 2)
-        stdscr.timeout(10)
+        curses.init_pair(1, 7, 0)
+        if not curses.can_change_color():
+            self.log_msg('@ unfortunately, your terminal does not seem to '
+                         'support re-definition of colors; you might miss out '
+                         'on some color effects')
         reset_screen_size()
         self.explorer = YX(0, 0)
         self.input_ = ''
         store_widechar = False
         input_prompt = '> '
         reset_screen_size()
         self.explorer = YX(0, 0)
         self.input_ = ''
         store_widechar = False
         input_prompt = '> '
-        interval = datetime.timedelta(seconds=5)
-        last_ping = datetime.datetime.now() - interval
         while True:
         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
+            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
             if self.do_refresh:
                 draw_screen()
                 self.do_refresh = False
             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
+            for msg in self.socket.get_message():
+                handle_input(msg)
             try:
             try:
-                key = stdscr.getkey()
+                key = self.screen.stdscr.getkey()
                 self.do_refresh = True
             except curses.error:
                 continue
                 self.do_refresh = True
             except curses.error:
                 continue
@@ -1233,7 +1225,7 @@ class TUI:
                 self.restore_input_values()
             elif self.mode.has_input_prompt and key != '\n':  # Return key
                 self.input_ += key
                 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
+                max_length = self.right_window_width * self.screen.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:
                 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:
@@ -1243,9 +1235,17 @@ class TUI:
                 self.send('LOGIN ' + quote(self.input_))
                 self.input_ = ""
             elif self.mode.name == 'enter_face' and key == '\n':
                 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')
+                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':
             elif self.mode.name == 'take_thing' and key == '\n':
                 pick_selectable('PICK_UP')
             elif self.mode.name == 'drop_thing' and key == '\n':
@@ -1344,7 +1344,7 @@ class TUI:
                     self.send('TASK:DANCE')
                 elif key == self.keys['teleport']:
                     if self.game.player.position in self.game.portals:
                     self.send('TASK:DANCE')
                 elif key == self.keys['teleport']:
                     if self.game.player.position in self.game.portals:
-                        self.host = self.game.portals[self.game.player.position]
+                        self.socket.host = self.game.portals[self.game.player.position]
                         self.reconnect()
                     else:
                         self.flash = True
                         self.reconnect()
                     else:
                         self.flash = True