home · contact · privacy
Replace client TURN line with frequently updated toilet need stat.
[plomrogue2] / rogue_chat_curses.py
1 #!/usr/bin/env python3
2 import curses
3 import queue
4 import threading
5 import time
6 import sys
7 from plomrogue.game import GameBase
8 from plomrogue.parser import Parser
9 from plomrogue.mapping import YX, MapGeometrySquare, MapGeometryHex
10 from plomrogue.things import ThingBase
11 from plomrogue.misc import quote
12 from plomrogue.errors import BrokenSocketConnection, ArgError
13
14 mode_helps = {
15     'play': {
16         'short': 'play',
17         'intro': '',
18         'long': 'This mode allows you to interact with the map in various ways.'
19     },
20     'study': {
21         'short': 'study',
22         'intro': '',
23         'long': 'This mode allows you to study the map and its tiles in detail.  Move the question mark over a tile, and the right half of the screen will show detailed information on it.  Toggle the map view to show or hide different information layers.'},
24     'edit': {
25         'short': 'world edit',
26         'intro': '',
27         'long': 'This mode allows you to change the game world in various ways.  Individual map tiles can be protected by "protection characters", which you can see by toggling into the protections map view.  You can edit a tile if you set the world edit password that matches its protection character.  The character "." marks the absence of protection:  Such tiles can always be edited.'
28     },
29     'name_thing': {
30         'short': 'name thing',
31         'intro': '',
32         'long': 'Give name to/change name of carried thing.'
33     },
34     'command_thing': {
35         'short': 'command',
36         'intro': '',
37         'long': 'Enter a command to the thing you carry.  Enter nothing to return to play mode.'
38     },
39     'take_thing': {
40         'short': 'take',
41         'intro': 'Pick up a thing in reach by entering its index number.  Enter nothing to abort.',
42         'long': 'You see a list of things which you could pick up.  Enter the target thing\'s index, or, to leave, nothing.'
43     },
44     'drop_thing': {
45         'short': 'drop',
46         'intro': 'Enter number of direction to which you want to drop thing.',
47         'long': 'Drop currently carried thing by entering the target direction index.  Enter nothing to return to play mode..'
48     },
49     'admin_thing_protect': {
50         'short': 'change thing protection',
51         'intro': '@ enter thing protection character:',
52         'long': 'Change protection character for carried thing.'
53     },
54     'enter_face': {
55         'short': 'edit face',
56         'intro': '@ enter face line (enter nothing to abort):',
57         '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..'
58     },
59     'enter_hat': {
60         'short': 'edit hat',
61         'intro': '@ enter hat line (enter nothing to abort):',
62         '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.'
63     },
64     'write': {
65         'short': 'edit tile',
66         'intro': '',
67         '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.'
68     },
69     'control_pw_type': {
70         'short': 'change protection character password',
71         'intro': '@ enter protection character for which you want to change the password:',
72         'long': 'This mode is the first of two steps to change the password for a protection character.  First enter the protection character for which you want to change the password.'
73     },
74     'control_pw_pw': {
75         'short': 'change protection character password',
76         'intro': '',
77         'long': 'This mode is the second of two steps to change the password for a protection character.  Enter the new password for the protection character you chose.'
78     },
79     'control_tile_type': {
80         'short': 'change tiles protection',
81         'intro': '@ enter protection character which you want to draw:',
82         'long': 'This mode is the first of two steps to change tile protection areas on the map.  First enter the tile protection character you want to write.'
83     },
84     'control_tile_draw': {
85         'short': 'change tiles protection',
86         'intro': '',
87         'long': 'This mode is the second of two steps to change tile protection areas on the map.  Toggle tile protection drawing on/off and move the ?? cursor around the map to draw the selected protection character.'
88     },
89     'annotate': {
90         'short': 'annotate tile',
91         'intro': '',
92         'long': 'This mode allows you to add/edit a comment on the tile you are currently standing on (provided your world editing password authorizes you so).  Hit Return to leave.'
93     },
94     'portal': {
95         'short': 'edit portal',
96         'intro': '',
97         'long': 'This mode allows you to imprint/edit/remove a teleportation target on the ground you are currently standing on (provided your world editing password authorizes you so).  Enter or edit a URL to imprint a teleportation target; enter emptiness to remove a pre-existing teleportation target.  Hit Return to leave.'
98     },
99     'chat': {
100         'short': 'chat',
101         'intro': '',
102         'long': 'This mode allows you to engage in chit-chat with other users.  Any line you enter into the input prompt that does not start with a "/" will be sent out to nearby players – but barriers and distance will reduce what they can read, so stand close to them to ensure they get your message.  Lines that start with a "/" are used for commands like:\n\n/nick NAME – re-name yourself to NAME'
103     },
104     'login': {
105         'short': 'login',
106         'intro': '',
107         'long': 'Enter your player name.'
108     },
109     'waiting_for_server': {
110         'short': 'waiting for server response',
111         'intro': '@ waiting for server …',
112         'long': 'Waiting for a server response.'
113     },
114     'post_login_wait': {
115         'short': 'waiting for server response',
116         'intro': '',
117         'long': 'Waiting for a server response.'
118     },
119     'password': {
120         'short': 'set world edit password',
121         'intro': '',
122         'long': 'This mode allows you to change the password that you send to authorize yourself for editing password-protected elements of the world.  Hit return to confirm and leave.'
123     },
124     'admin_enter': {
125         'short': 'become admin',
126         'intro': '@ enter admin password:',
127         'long': 'This mode allows you to become admin if you know an admin password.'
128     },
129     'admin': {
130         'short': 'admin',
131         'intro': '',
132         'long': 'This mode allows you access to actions limited to administrators.'
133     }
134 }
135
136 from ws4py.client import WebSocketBaseClient
137 class WebSocketClient(WebSocketBaseClient):
138
139     def __init__(self, recv_handler, *args, **kwargs):
140         super().__init__(*args, **kwargs)
141         self.recv_handler = recv_handler
142         self.connect()
143
144     def received_message(self, message):
145         if message.is_text:
146             message = str(message)
147             self.recv_handler(message)
148
149     @property
150     def plom_closed(self):
151         return self.client_terminated
152
153 from plomrogue.io_tcp import PlomSocket
154 class PlomSocketClient(PlomSocket):
155
156     def __init__(self, recv_handler, url):
157         import socket
158         self.recv_handler = recv_handler
159         host, port = url.split(':')
160         super().__init__(socket.create_connection((host, port)))
161
162     def close(self):
163         self.socket.close()
164
165     def run(self):
166         import ssl
167         try:
168             for msg in self.recv():
169                 if msg == 'NEED_SSL':
170                     self.socket = ssl.wrap_socket(self.socket)
171                     continue
172                 self.recv_handler(msg)
173         except BrokenSocketConnection:
174             pass  # we assume socket will be known as dead by now
175
176 def cmd_TURN(game, n):
177     game.turn_complete = False
178 cmd_TURN.argtypes = 'int:nonneg'
179
180 def cmd_OTHER_WIPE(game):
181     game.portals_new = {}
182     game.annotations_new = {}
183     game.things_new = []
184 cmd_OTHER_WIPE.argtypes = ''
185
186 def cmd_LOGIN_OK(game):
187     game.tui.switch_mode('post_login_wait')
188     game.tui.send('GET_GAMESTATE')
189     game.tui.log_msg('@ welcome')
190 cmd_LOGIN_OK.argtypes = ''
191
192 def cmd_ADMIN_OK(game):
193     game.tui.is_admin = True
194     game.tui.log_msg('@ you now have admin rights')
195     game.tui.switch_mode('admin')
196     game.tui.do_refresh = True
197 cmd_ADMIN_OK.argtypes = ''
198
199 def cmd_REPLY(game, msg):
200     game.tui.log_msg('#MUSICPLAYER: ' + msg)
201     game.tui.do_refresh = True
202 cmd_REPLY.argtypes = 'string'
203
204 def cmd_CHAT(game, msg):
205     game.tui.log_msg('# ' + msg)
206     game.tui.do_refresh = True
207 cmd_CHAT.argtypes = 'string'
208
209 def cmd_CHATFACE(game, thing_id):
210     game.tui.draw_face = thing_id
211     game.tui.do_refresh = True
212 cmd_CHATFACE.argtypes = 'int:pos'
213
214 def cmd_PLAYER_ID(game, player_id):
215     game.player_id = player_id
216 cmd_PLAYER_ID.argtypes = 'int:nonneg'
217
218 def cmd_PLAYERS_HAT_CHARS(game, hat_chars):
219     game.players_hat_chars_new = hat_chars
220 cmd_PLAYERS_HAT_CHARS.argtypes = 'string'
221
222 def cmd_THING(game, yx, thing_type, protection, thing_id, portable, commandable):
223     t = game.get_thing_temp(thing_id)
224     if not t:
225         t = ThingBase(game, thing_id)
226         game.things_new += [t]
227     t.position = yx
228     t.type_ = thing_type
229     t.protection = protection
230     t.portable = portable
231     t.commandable = commandable
232 cmd_THING.argtypes = 'yx_tuple:nonneg string:thing_type char int:nonneg bool bool'
233
234 def cmd_THING_NAME(game, thing_id, name):
235     t = game.get_thing_temp(thing_id)
236     t.name = name
237 cmd_THING_NAME.argtypes = 'int:pos string'
238
239 def cmd_THING_FACE(game, thing_id, face):
240     t = game.get_thing_temp(thing_id)
241     t.face = face
242 cmd_THING_FACE.argtypes = 'int:pos string'
243
244 def cmd_THING_HAT(game, thing_id, hat):
245     t = game.get_thing_temp(thing_id)
246     t.hat = hat
247 cmd_THING_HAT.argtypes = 'int:pos string'
248
249 def cmd_THING_CHAR(game, thing_id, c):
250     t = game.get_thing_temp(thing_id)
251     t.thing_char = c
252 cmd_THING_CHAR.argtypes = 'int:pos char'
253
254 def cmd_MAP(game, geometry, size, content):
255     map_geometry_class = globals()['MapGeometry' + geometry]
256     game.map_geometry_new = map_geometry_class(size)
257     game.map_content_new = content
258     if type(game.map_geometry_new) == MapGeometrySquare:
259         game.tui.movement_keys = {
260             game.tui.keys['square_move_up']: 'UP',
261             game.tui.keys['square_move_left']: 'LEFT',
262             game.tui.keys['square_move_down']: 'DOWN',
263             game.tui.keys['square_move_right']: 'RIGHT',
264         }
265     elif type(game.map_geometry_new) == MapGeometryHex:
266         game.tui.movement_keys = {
267             game.tui.keys['hex_move_upleft']: 'UPLEFT',
268             game.tui.keys['hex_move_upright']: 'UPRIGHT',
269             game.tui.keys['hex_move_right']: 'RIGHT',
270             game.tui.keys['hex_move_downright']: 'DOWNRIGHT',
271             game.tui.keys['hex_move_downleft']: 'DOWNLEFT',
272             game.tui.keys['hex_move_left']: 'LEFT',
273         }
274 cmd_MAP.argtypes = 'string:map_geometry yx_tuple:pos string'
275
276 def cmd_FOV(game, content):
277     game.fov_new = content
278 cmd_FOV.argtypes = 'string'
279
280 def cmd_MAP_CONTROL(game, content):
281     game.map_control_content_new = content
282 cmd_MAP_CONTROL.argtypes = 'string'
283
284 def cmd_GAME_STATE_COMPLETE(game):
285     game.tui.do_refresh = True
286     game.tui.info_cached = None
287     game.things = game.things_new
288     game.portals = game.portals_new
289     game.annotations = game.annotations_new
290     game.fov = game.fov_new
291     game.map_geometry = game.map_geometry_new
292     game.map_content = game.map_content_new
293     game.map_control_content = game.map_control_content_new
294     game.player = game.get_thing(game.player_id)
295     game.players_hat_chars = game.players_hat_chars_new
296     game.bladder_pressure = game.bladder_pressure_new
297     game.turn_complete = True
298     if game.tui.mode.name == 'post_login_wait':
299         game.tui.switch_mode('play')
300 cmd_GAME_STATE_COMPLETE.argtypes = ''
301
302 def cmd_PORTAL(game, position, msg):
303     game.portals_new[position] = msg
304 cmd_PORTAL.argtypes = 'yx_tuple:nonneg string'
305
306 def cmd_PLAY_ERROR(game, msg):
307     game.tui.log_msg('? ' + msg)
308     game.tui.flash = True
309     game.tui.do_refresh = True
310 cmd_PLAY_ERROR.argtypes = 'string'
311
312 def cmd_GAME_ERROR(game, msg):
313     game.tui.log_msg('? game error: ' + msg)
314     game.tui.do_refresh = True
315 cmd_GAME_ERROR.argtypes = 'string'
316
317 def cmd_ARGUMENT_ERROR(game, msg):
318     game.tui.log_msg('? syntax error: ' + msg)
319     game.tui.do_refresh = True
320 cmd_ARGUMENT_ERROR.argtypes = 'string'
321
322 def cmd_ANNOTATION(game, position, msg):
323     game.annotations_new[position] = msg
324     if game.tui.mode.shows_info:
325         game.tui.do_refresh = True
326 cmd_ANNOTATION.argtypes = 'yx_tuple:nonneg string'
327
328 def cmd_TASKS(game, tasks_comma_separated):
329     game.tasks = tasks_comma_separated.split(',')
330     game.tui.mode_write.legal = 'WRITE' in game.tasks
331     game.tui.mode_command_thing.legal = 'COMMAND' in game.tasks
332     game.tui.mode_take_thing.legal = 'PICK_UP' in game.tasks
333     game.tui.mode_drop_thing.legal = 'DROP' in game.tasks
334 cmd_TASKS.argtypes = 'string'
335
336 def cmd_THING_TYPE(game, thing_type, symbol_hint):
337     game.thing_types[thing_type] = symbol_hint
338 cmd_THING_TYPE.argtypes = 'string char'
339
340 def cmd_THING_INSTALLED(game, thing_id):
341     game.get_thing_temp(thing_id).installed = True
342 cmd_THING_INSTALLED.argtypes = 'int:pos'
343
344 def cmd_THING_CARRYING(game, thing_id, carried_id):
345     game.get_thing_temp(thing_id).carrying = game.get_thing_temp(carried_id)
346 cmd_THING_CARRYING.argtypes = 'int:pos int:pos'
347
348 def cmd_TERRAIN(game, terrain_char, terrain_desc):
349     game.terrains[terrain_char] = terrain_desc
350 cmd_TERRAIN.argtypes = 'char string'
351
352 def cmd_PONG(game):
353     pass
354 cmd_PONG.argtypes = ''
355
356 def cmd_DEFAULT_COLORS(game):
357     game.tui.set_default_colors()
358 cmd_DEFAULT_COLORS.argtypes = ''
359
360 def cmd_RANDOM_COLORS(game):
361     game.tui.set_random_colors()
362 cmd_RANDOM_COLORS.argtypes = ''
363
364 def cmd_BLADDER_PRESSURE(game, bladder_pressure):
365     game.bladder_pressure_new = bladder_pressure
366 cmd_BLADDER_PRESSURE.argtypes = 'int:nonneg'
367
368 class Game(GameBase):
369     turn_complete = False
370     tasks = {}
371     thing_types = {}
372     things_new = []
373
374     def __init__(self, *args, **kwargs):
375         super().__init__(*args, **kwargs)
376         self.register_command(cmd_LOGIN_OK)
377         self.register_command(cmd_ADMIN_OK)
378         self.register_command(cmd_PONG)
379         self.register_command(cmd_CHAT)
380         self.register_command(cmd_CHATFACE)
381         self.register_command(cmd_REPLY)
382         self.register_command(cmd_PLAYER_ID)
383         self.register_command(cmd_TURN)
384         self.register_command(cmd_OTHER_WIPE)
385         self.register_command(cmd_THING)
386         self.register_command(cmd_THING_TYPE)
387         self.register_command(cmd_THING_NAME)
388         self.register_command(cmd_THING_CHAR)
389         self.register_command(cmd_THING_FACE)
390         self.register_command(cmd_THING_HAT)
391         self.register_command(cmd_THING_CARRYING)
392         self.register_command(cmd_THING_INSTALLED)
393         self.register_command(cmd_TERRAIN)
394         self.register_command(cmd_MAP)
395         self.register_command(cmd_MAP_CONTROL)
396         self.register_command(cmd_PORTAL)
397         self.register_command(cmd_ANNOTATION)
398         self.register_command(cmd_GAME_STATE_COMPLETE)
399         self.register_command(cmd_PLAYERS_HAT_CHARS)
400         self.register_command(cmd_ARGUMENT_ERROR)
401         self.register_command(cmd_GAME_ERROR)
402         self.register_command(cmd_PLAY_ERROR)
403         self.register_command(cmd_TASKS)
404         self.register_command(cmd_FOV)
405         self.register_command(cmd_DEFAULT_COLORS)
406         self.register_command(cmd_RANDOM_COLORS)
407         self.register_command(cmd_BLADDER_PRESSURE)
408         self.map_content = ''
409         self.players_hat_chars = ''
410         self.player_id = -1
411         self.annotations = {}
412         self.annotations_new = {}
413         self.portals = {}
414         self.portals_new = {}
415         self.terrains = {}
416         self.player = None
417         self.bladder_pressure_new = 0
418         self.bladder_pressure = 0
419
420     def get_string_options(self, string_option_type):
421         if string_option_type == 'map_geometry':
422             return ['Hex', 'Square']
423         elif string_option_type == 'thing_type':
424             return self.thing_types.keys()
425         return None
426
427     def get_command(self, command_name):
428         from functools import partial
429         f = partial(self.commands[command_name], self)
430         f.argtypes = self.commands[command_name].argtypes
431         return f
432
433     def get_thing_temp(self, id_):
434         for thing in self.things_new:
435             if id_ == thing.id_:
436                 return thing
437         return None
438
439 class Mode:
440
441     def __init__(self, name, has_input_prompt=False, shows_info=False,
442                  is_intro=False, is_single_char_entry=False):
443         self.name = name
444         self.short_desc = mode_helps[name]['short']
445         self.available_modes = []
446         self.available_actions = []
447         self.has_input_prompt = has_input_prompt
448         self.shows_info = shows_info
449         self.is_intro = is_intro
450         self.help_intro = mode_helps[name]['long']
451         self.intro_msg = mode_helps[name]['intro']
452         self.is_single_char_entry = is_single_char_entry
453         self.legal = True
454
455     def iter_available_modes(self, tui):
456         for mode_name in self.available_modes:
457             mode = getattr(tui, 'mode_' + mode_name)
458             if not mode.legal:
459                 continue
460             key = tui.keys['switch_to_' + mode.name]
461             yield mode, key
462
463     def list_available_modes(self, tui):
464         msg = ''
465         if len(self.available_modes) > 0:
466             msg = 'Other modes available from here:\n'
467             for mode, key in self.iter_available_modes(tui):
468                 msg += '[%s] – %s\n' % (key, mode.short_desc)
469         return msg
470
471     def mode_switch_on_key(self, tui, key_pressed):
472         for mode, key in self.iter_available_modes(tui):
473             if key_pressed == key:
474                 tui.switch_mode(mode.name)
475                 return True
476         return False
477
478 class TUI:
479     mode_admin_enter = Mode('admin_enter', has_input_prompt=True)
480     mode_admin = Mode('admin')
481     mode_play = Mode('play')
482     mode_study = Mode('study', shows_info=True)
483     mode_write = Mode('write', is_single_char_entry=True)
484     mode_edit = Mode('edit')
485     mode_control_pw_type = Mode('control_pw_type', has_input_prompt=True)
486     mode_control_pw_pw = Mode('control_pw_pw', has_input_prompt=True)
487     mode_control_tile_type = Mode('control_tile_type', has_input_prompt=True)
488     mode_control_tile_draw = Mode('control_tile_draw')
489     mode_admin_thing_protect = Mode('admin_thing_protect', has_input_prompt=True)
490     mode_annotate = Mode('annotate', has_input_prompt=True, shows_info=True)
491     mode_portal = Mode('portal', has_input_prompt=True, shows_info=True)
492     mode_chat = Mode('chat', has_input_prompt=True)
493     mode_waiting_for_server = Mode('waiting_for_server', is_intro=True)
494     mode_login = Mode('login', has_input_prompt=True, is_intro=True)
495     mode_post_login_wait = Mode('post_login_wait', is_intro=True)
496     mode_password = Mode('password', has_input_prompt=True)
497     mode_name_thing = Mode('name_thing', has_input_prompt=True, shows_info=True)
498     mode_command_thing = Mode('command_thing', has_input_prompt=True)
499     mode_take_thing = Mode('take_thing', has_input_prompt=True)
500     mode_drop_thing = Mode('drop_thing', has_input_prompt=True)
501     mode_enter_face = Mode('enter_face', has_input_prompt=True)
502     mode_enter_hat = Mode('enter_hat', has_input_prompt=True)
503     is_admin = False
504     tile_draw = False
505
506     def __init__(self, host):
507         import os
508         import json
509         self.mode_play.available_modes = ["chat", "study", "edit", "admin_enter",
510                                           "command_thing", "take_thing",
511                                           "drop_thing"]
512         self.mode_play.available_actions = ["move", "teleport", "door", "consume",
513                                             "install", "wear", "spin"]
514         self.mode_study.available_modes = ["chat", "play", "admin_enter", "edit"]
515         self.mode_study.available_actions = ["toggle_map_mode", "move_explorer"]
516         self.mode_admin.available_modes = ["admin_thing_protect", "control_pw_type",
517                                            "control_tile_type", "chat",
518                                            "study", "play", "edit"]
519         self.mode_admin.available_actions = ["move"]
520         self.mode_control_tile_draw.available_modes = ["admin_enter"]
521         self.mode_control_tile_draw.available_actions = ["move_explorer",
522                                                          "toggle_tile_draw"]
523         self.mode_edit.available_modes = ["write", "annotate", "portal",
524                                           "name_thing", "enter_face", "enter_hat",
525                                           "password",
526                                           "chat", "study", "play", "admin_enter"]
527         self.mode_edit.available_actions = ["move", "flatten", "install",
528                                             "toggle_map_mode"]
529         self.mode = None
530         self.host = host
531         self.game = Game()
532         self.game.tui = self
533         self.parser = Parser(self.game)
534         self.log = []
535         self.do_refresh = True
536         self.queue = queue.Queue()
537         self.login_name = None
538         self.map_mode = 'terrain + things'
539         self.password = 'foo'
540         self.switch_mode('waiting_for_server')
541         self.keys = {
542             'switch_to_chat': 't',
543             'switch_to_play': 'p',
544             'switch_to_password': 'P',
545             'switch_to_annotate': 'M',
546             'switch_to_portal': 'T',
547             'switch_to_study': '?',
548             'switch_to_edit': 'E',
549             'switch_to_write': 'm',
550             'switch_to_name_thing': 'N',
551             'switch_to_command_thing': 'O',
552             'switch_to_admin_enter': 'A',
553             'switch_to_control_pw_type': 'C',
554             'switch_to_control_tile_type': 'Q',
555             'switch_to_admin_thing_protect': 'T',
556             'flatten': 'F',
557             'switch_to_enter_face': 'f',
558             'switch_to_enter_hat': 'H',
559             'switch_to_take_thing': 'z',
560             'switch_to_drop_thing': 'u',
561             'teleport': 'p',
562             'consume': 'C',
563             'door': 'D',
564             'install': 'I',
565             'wear': 'W',
566             'spin': 'S',
567             'help': 'h',
568             'toggle_map_mode': 'L',
569             'toggle_tile_draw': 'm',
570             'hex_move_upleft': 'w',
571             'hex_move_upright': 'e',
572             'hex_move_right': 'd',
573             'hex_move_downright': 'x',
574             'hex_move_downleft': 'y',
575             'hex_move_left': 'a',
576             'square_move_up': 'w',
577             'square_move_left': 'a',
578             'square_move_down': 's',
579             'square_move_right': 'd',
580         }
581         if os.path.isfile('config.json'):
582             with open('config.json', 'r') as f:
583                 keys_conf = json.loads(f.read())
584             for k in keys_conf:
585                 self.keys[k] = keys_conf[k]
586         self.show_help = False
587         self.disconnected = True
588         self.force_instant_connect = True
589         self.input_lines = []
590         self.fov = ''
591         self.flash = False
592         self.map_lines = []
593         self.ascii_draw_stage = 0
594         self.full_ascii_draw = ''
595         self.offset = YX(0,0)
596         curses.wrapper(self.loop)
597
598     def connect(self):
599
600         def handle_recv(msg):
601             if msg == 'BYE':
602                 self.socket.close()
603             else:
604                 self.queue.put(msg)
605
606         self.log_msg('@ attempting connect')
607         socket_client_class = PlomSocketClient
608         if self.host.startswith('ws://') or self.host.startswith('wss://'):
609             socket_client_class = WebSocketClient
610         try:
611             self.socket = socket_client_class(handle_recv, self.host)
612             self.socket_thread = threading.Thread(target=self.socket.run)
613             self.socket_thread.start()
614             self.disconnected = False
615             self.game.thing_types = {}
616             self.game.terrains = {}
617             time.sleep(0.1)  # give potential SSL negotation some time …
618             self.socket.send('TASKS')
619             self.socket.send('TERRAINS')
620             self.socket.send('THING_TYPES')
621             self.switch_mode('login')
622         except ConnectionRefusedError:
623             self.log_msg('@ server connect failure')
624             self.disconnected = True
625             self.switch_mode('waiting_for_server')
626         self.do_refresh = True
627
628     def reconnect(self):
629         self.log_msg('@ attempting reconnect')
630         self.send('QUIT')
631         # necessitated by some strange SSL race conditions with ws4py
632         time.sleep(0.1)  # FIXME find out why exactly necessary
633         self.switch_mode('waiting_for_server')
634         self.connect()
635
636     def send(self, msg):
637         try:
638             if hasattr(self.socket, 'plom_closed') and self.socket.plom_closed:
639                 raise BrokenSocketConnection
640             self.socket.send(msg)
641         except (BrokenPipeError, BrokenSocketConnection):
642             self.log_msg('@ server disconnected :(')
643             self.disconnected = True
644             self.force_instant_connect = True
645             self.do_refresh = True
646
647     def log_msg(self, msg):
648         self.log += [msg]
649         if len(self.log) > 100:
650             self.log = self.log[-100:]
651
652     def restore_input_values(self):
653         if self.mode.name == 'annotate' and self.explorer in self.game.annotations:
654             self.input_ = self.game.annotations[self.explorer]
655         elif self.mode.name == 'portal' and self.explorer in self.game.portals:
656             self.input_ = self.game.portals[self.explorer]
657         elif self.mode.name == 'password':
658             self.input_ = self.password
659         elif self.mode.name == 'name_thing':
660             if hasattr(self.game.player.carrying, 'name'):
661                 self.input_ = self.game.player.carrying.name
662         elif self.mode.name == 'admin_thing_protect':
663             if hasattr(self.game.player.carrying, 'protection'):
664                 self.input_ = self.game.player.carrying.protection
665         elif self.mode.name in {'enter_face', 'enter_hat'}:
666             start = self.ascii_draw_stage * 6
667             end = (self.ascii_draw_stage + 1) * 6
668             if self.mode.name == 'enter_face':
669                 self.input_ = self.game.player.face[start:end]
670             elif self.mode.name == 'enter_hat':
671                 self.input_ = self.game.player.hat[start:end]
672
673     def send_tile_control_command(self):
674         self.send('SET_TILE_CONTROL %s %s' %
675                   (self.explorer, quote(self.tile_control_char)))
676
677     def toggle_map_mode(self):
678         if self.map_mode == 'terrain only':
679             self.map_mode = 'terrain + annotations'
680         elif self.map_mode == 'terrain + annotations':
681             self.map_mode = 'terrain + things'
682         elif self.map_mode == 'terrain + things':
683             self.map_mode = 'protections'
684         elif self.map_mode == 'protections':
685             self.map_mode = 'terrain only'
686
687     def switch_mode(self, mode_name):
688
689         def fail(msg, return_mode='play'):
690             self.log_msg('? ' + msg)
691             self.flash = True
692             self.switch_mode(return_mode)
693
694         if self.mode and self.mode.name == 'control_tile_draw':
695             self.log_msg('@ finished tile protection drawing.')
696         self.draw_face = False
697         self.tile_draw = False
698         if mode_name == 'command_thing' and\
699            (not self.game.player.carrying or
700             not self.game.player.carrying.commandable):
701             return fail('not carrying anything commandable')
702         if mode_name == 'name_thing' and not self.game.player.carrying:
703             return fail('not carrying anything to re-name')
704         if mode_name == 'admin_thing_protect' and not self.game.player.carrying:
705             return fail('not carrying anything to protect')
706         if mode_name == 'take_thing' and self.game.player.carrying:
707             return fail('already carrying something')
708         if mode_name == 'drop_thing' and not self.game.player.carrying:
709             return fail('not carrying anything droppable')
710         if mode_name == 'enter_hat' and not hasattr(self.game.player, 'hat'):
711             return fail('not wearing hat to edit', 'edit')
712         if mode_name == 'admin_enter' and self.is_admin:
713             mode_name = 'admin'
714         self.mode = getattr(self, 'mode_' + mode_name)
715         if self.mode.name in {'control_tile_draw', 'control_tile_type',
716                               'control_pw_type'}:
717             self.map_mode = 'protections'
718         elif self.mode.name != 'edit':
719             self.map_mode = 'terrain + things'
720         if self.mode.shows_info or self.mode.name == 'control_tile_draw':
721             self.explorer = YX(self.game.player.position.y,
722                                self.game.player.position.x)
723         if self.mode.is_single_char_entry:
724             self.show_help = True
725         if len(self.mode.intro_msg) > 0:
726             self.log_msg(self.mode.intro_msg)
727         if self.mode.name == 'login':
728             if self.login_name:
729                 self.send('LOGIN ' + quote(self.login_name))
730             else:
731                 self.log_msg('@ enter username')
732         elif self.mode.name == 'take_thing':
733             self.log_msg('Portable things in reach for pick-up:')
734             directed_moves = {
735                 'HERE': YX(0, 0), 'LEFT': YX(0, -1), 'RIGHT': YX(0, 1)
736             }
737             if type(self.game.map_geometry) == MapGeometrySquare:
738                 directed_moves['UP'] = YX(-1, 0)
739                 directed_moves['DOWN'] = YX(1, 0)
740             elif type(self.game.map_geometry) == MapGeometryHex:
741                 if self.game.player.position.y % 2:
742                     directed_moves['UPLEFT'] = YX(-1, 0)
743                     directed_moves['UPRIGHT'] = YX(-1, 1)
744                     directed_moves['DOWNLEFT'] = YX(1, 0)
745                     directed_moves['DOWNRIGHT'] = YX(1, 1)
746                 else:
747                     directed_moves['UPLEFT'] = YX(-1, -1)
748                     directed_moves['UPRIGHT'] = YX(-1, 0)
749                     directed_moves['DOWNLEFT'] = YX(1, -1)
750                     directed_moves['DOWNRIGHT'] = YX(1, 0)
751             select_range = {}
752             for direction in directed_moves:
753                 move = directed_moves[direction]
754                 select_range[direction] = self.game.player.position + move
755             self.selectables = []
756             directions = []
757             for direction in select_range:
758                 for t in [t for t in self.game.things
759                           if t.portable and t.position == select_range[direction]]:
760                     self.selectables += [t.id_]
761                     directions += [direction]
762             if len(self.selectables) == 0:
763                 return fail('nothing to pick-up')
764             else:
765                 for i in range(len(self.selectables)):
766                     t = self.game.get_thing(self.selectables[i])
767                     self.log_msg('%s %s: %s' % (i, directions[i],
768                                                 self.get_thing_info(t)))
769         elif self.mode.name == 'drop_thing':
770             self.log_msg('Direction to drop thing to:')
771             self.selectables =\
772                 ['HERE'] + list(self.game.tui.movement_keys.values())
773             for i in range(len(self.selectables)):
774                 self.log_msg(str(i) + ': ' + self.selectables[i])
775         elif self.mode.name == 'enter_hat':
776             self.log_msg('legal characters: ' + self.game.players_hat_chars)
777         elif self.mode.name == 'command_thing':
778             self.send('TASK:COMMAND ' + quote('HELP'))
779         elif self.mode.name == 'control_pw_pw':
780             self.log_msg('@ enter protection password for "%s":' % self.tile_control_char)
781         elif self.mode.name == 'control_tile_draw':
782             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']))
783         self.input_ = ""
784         self.restore_input_values()
785
786     def set_default_colors(self):
787         curses.init_color(1, 1000, 1000, 1000)
788         curses.init_color(2, 0, 0, 0)
789         self.do_refresh = True
790
791     def set_random_colors(self):
792
793         def rand(offset):
794             import random
795             return int(offset + random.random()*375)
796
797         curses.init_color(1, rand(625), rand(625), rand(625))
798         curses.init_color(2, rand(0), rand(0), rand(0))
799         self.do_refresh = True
800
801     def get_info(self):
802         if self.info_cached:
803             return self.info_cached
804         pos_i = self.explorer.y * self.game.map_geometry.size.x + self.explorer.x
805         info_to_cache = ''
806         if len(self.game.fov) > pos_i and self.game.fov[pos_i] != '.':
807             info_to_cache += 'outside field of view'
808         else:
809             for t in self.game.things:
810                 if t.position == self.explorer:
811                     info_to_cache += 'THING: %s' % self.get_thing_info(t)
812                     protection = t.protection
813                     if protection == '.':
814                         protection = 'none'
815                     info_to_cache += ' / protection: %s\n' % protection
816                     if hasattr(t, 'hat'):
817                         info_to_cache += t.hat[0:6] + '\n'
818                         info_to_cache += t.hat[6:12] + '\n'
819                         info_to_cache += t.hat[12:18] + '\n'
820                     if hasattr(t, 'face'):
821                         info_to_cache += t.face[0:6] + '\n'
822                         info_to_cache += t.face[6:12] + '\n'
823                         info_to_cache += t.face[12:18] + '\n'
824             terrain_char = self.game.map_content[pos_i]
825             terrain_desc = '?'
826             if terrain_char in self.game.terrains:
827                 terrain_desc = self.game.terrains[terrain_char]
828             info_to_cache += 'TERRAIN: "%s" / %s\n' % (terrain_char,
829                                                        terrain_desc)
830             protection = self.game.map_control_content[pos_i]
831             if protection == '.':
832                 protection = 'unprotected'
833             info_to_cache += 'PROTECTION: %s\n' % protection
834             if self.explorer in self.game.portals:
835                 info_to_cache += 'PORTAL: ' +\
836                     self.game.portals[self.explorer] + '\n'
837             else:
838                 info_to_cache += 'PORTAL: (none)\n'
839             if self.explorer in self.game.annotations:
840                 info_to_cache += 'ANNOTATION: ' +\
841                     self.game.annotations[self.explorer]
842         self.info_cached = info_to_cache
843         return self.info_cached
844
845     def get_thing_info(self, t):
846         info = '%s / %s' %\
847             (t.type_, self.game.thing_types[t.type_])
848         if hasattr(t, 'thing_char'):
849             info += t.thing_char
850         if hasattr(t, 'name'):
851             info += ' (%s)' % t.name
852         if hasattr(t, 'installed'):
853             info += ' / installed'
854         return info
855
856     def loop(self, stdscr):
857         import datetime
858
859         def safe_addstr(y, x, line):
860             if y < self.size.y - 1 or x + len(line) < self.size.x:
861                 stdscr.addstr(y, x, line, curses.color_pair(1))
862             else:  # workaround to <https://stackoverflow.com/q/7063128>
863                 cut_i = self.size.x - x - 1
864                 cut = line[:cut_i]
865                 last_char = line[cut_i]
866                 stdscr.addstr(y, self.size.x - 2, last_char, curses.color_pair(1))
867                 stdscr.insstr(y, self.size.x - 2, ' ')
868                 stdscr.addstr(y, x, cut, curses.color_pair(1))
869
870         def handle_input(msg):
871             command, args = self.parser.parse(msg)
872             command(*args)
873
874         def task_action_on(action):
875             return action_tasks[action] in self.game.tasks
876
877         def msg_into_lines_of_width(msg, width):
878             chunk = ''
879             lines = []
880             x = 0
881             for i in range(len(msg)):
882                 if x >= width or msg[i] == "\n":
883                     lines += [chunk]
884                     chunk = ''
885                     x = 0
886                     if msg[i] == "\n":
887                         x -= 1
888                 if msg[i] != "\n":
889                     chunk += msg[i]
890                 x += 1
891             lines += [chunk]
892             return lines
893
894         def reset_screen_size():
895             self.size = YX(*stdscr.getmaxyx())
896             self.size = self.size - YX(self.size.y % 4, 0)
897             self.size = self.size - YX(0, self.size.x % 4)
898             self.window_width = int(self.size.x / 2)
899
900         def recalc_input_lines():
901             if not self.mode.has_input_prompt:
902                 self.input_lines = []
903             else:
904                 self.input_lines = msg_into_lines_of_width(input_prompt
905                                                            + self.input_ + '█',
906                                                            self.window_width)
907
908         def move_explorer(direction):
909             target = self.game.map_geometry.move_yx(self.explorer, direction)
910             if target:
911                 self.info_cached = None
912                 self.explorer = target
913                 if self.tile_draw:
914                     self.send_tile_control_command()
915             else:
916                 self.flash = True
917
918         def draw_history():
919             lines = []
920             for line in self.log:
921                 lines += msg_into_lines_of_width(line, self.window_width)
922             lines.reverse()
923             height_header = 2
924             max_y = self.size.y - len(self.input_lines)
925             for i in range(len(lines)):
926                 if (i >= max_y - height_header):
927                     break
928                 safe_addstr(max_y - i - 1, self.window_width, lines[i])
929
930         def draw_info():
931             info = 'MAP VIEW: %s\n%s' % (self.map_mode, self.get_info())
932             lines = msg_into_lines_of_width(info, self.window_width)
933             height_header = 2
934             for i in range(len(lines)):
935                 y = height_header + i
936                 if y >= self.size.y - len(self.input_lines):
937                     break
938                 safe_addstr(y, self.window_width, lines[i])
939
940         def draw_input():
941             y = self.size.y - len(self.input_lines)
942             for i in range(len(self.input_lines)):
943                 safe_addstr(y, self.window_width, self.input_lines[i])
944                 y += 1
945
946         def draw_stats():
947             safe_addstr(0, self.window_width, 'BLADDER: ' + str(self.game.bladder_pressure))
948
949         def draw_mode():
950             help = "hit [%s] for help" % self.keys['help']
951             if self.mode.has_input_prompt:
952                 help = "enter /help for help"
953             safe_addstr(1, self.window_width,
954                         'MODE: %s – %s' % (self.mode.short_desc, help))
955
956         def draw_map():
957             if (not self.game.turn_complete) and len(self.map_lines) == 0:
958                 return
959             if self.game.turn_complete:
960                 map_lines_split = []
961                 for y in range(self.game.map_geometry.size.y):
962                     start = self.game.map_geometry.size.x * y
963                     end = start + self.game.map_geometry.size.x
964                     if self.map_mode == 'protections':
965                         map_lines_split += [[c + ' ' for c
966                                              in self.game.map_control_content[start:end]]]
967                     else:
968                         map_lines_split += [[c + ' ' for c
969                                              in self.game.map_content[start:end]]]
970                 if self.map_mode == 'terrain + annotations':
971                     for p in self.game.annotations:
972                         map_lines_split[p.y][p.x] = 'A '
973                 elif self.map_mode == 'terrain + things':
974                     for p in self.game.portals.keys():
975                         original = map_lines_split[p.y][p.x]
976                         map_lines_split[p.y][p.x] = original[0] + 'P'
977                     used_positions = []
978
979                     def draw_thing(t, used_positions):
980                         symbol = self.game.thing_types[t.type_]
981                         meta_char = ' '
982                         if hasattr(t, 'thing_char'):
983                             meta_char = t.thing_char
984                         if t.position in used_positions:
985                             meta_char = '+'
986                         if hasattr(t, 'carrying') and t.carrying:
987                             meta_char = '$'
988                         map_lines_split[t.position.y][t.position.x] = symbol + meta_char
989                         used_positions += [t.position]
990
991                     for t in [t for t in self.game.things if t.type_ != 'Player']:
992                         draw_thing(t, used_positions)
993                     for t in [t for t in self.game.things if t.type_ == 'Player']:
994                         draw_thing(t, used_positions)
995                 if self.mode.shows_info or self.mode.name == 'control_tile_draw':
996                     map_lines_split[self.explorer.y][self.explorer.x] = '??'
997                 elif self.map_mode != 'terrain + things':
998                     map_lines_split[self.game.player.position.y]\
999                         [self.game.player.position.x] = '??'
1000                 self.map_lines = []
1001                 if type(self.game.map_geometry) == MapGeometryHex:
1002                     indent = 0
1003                     for line in map_lines_split:
1004                         self.map_lines += [indent * ' ' + ''.join(line)]
1005                         indent = 0 if indent else 1
1006                 else:
1007                     for line in map_lines_split:
1008                         self.map_lines += [''.join(line)]
1009                 window_center = YX(int(self.size.y / 2),
1010                                    int(self.window_width / 2))
1011                 center = self.game.player.position
1012                 if self.mode.shows_info or self.mode.name == 'control_tile_draw':
1013                     center = self.explorer
1014                 center = YX(center.y, center.x * 2)
1015                 self.offset = center - window_center
1016                 if type(self.game.map_geometry) == MapGeometryHex and self.offset.y % 2:
1017                     self.offset += YX(0, 1)
1018             term_y = max(0, -self.offset.y)
1019             term_x = max(0, -self.offset.x)
1020             map_y = max(0, self.offset.y)
1021             map_x = max(0, self.offset.x)
1022             while term_y < self.size.y and map_y < len(self.map_lines):
1023                 to_draw = self.map_lines[map_y][map_x:self.window_width + self.offset.x]
1024                 safe_addstr(term_y, term_x, to_draw)
1025                 term_y += 1
1026                 map_y += 1
1027
1028         def draw_face_popup():
1029             t = self.game.get_thing(self.draw_face)
1030             if not t or not hasattr(t, 'face'):
1031                 self.draw_face = False
1032                 return
1033
1034             start_x = self.window_width - 10
1035             t_char = ' '
1036             if hasattr(t, 'thing_char'):
1037                 t_char = t.thing_char
1038             def draw_body_part(body_part, end_y):
1039                 safe_addstr(end_y - 4, start_x, ' _[ @' + t_char + ' ]_ ')
1040                 safe_addstr(end_y - 3, start_x, '|        |')
1041                 safe_addstr(end_y - 2, start_x, '| ' + body_part[0:6] + ' |')
1042                 safe_addstr(end_y - 1, start_x, '| ' + body_part[6:12] + ' |')
1043                 safe_addstr(end_y, start_x, '| ' + body_part[12:18] + ' |')
1044
1045             if hasattr(t, 'face'):
1046                 draw_body_part(t.face, self.size.y - 2)
1047             if hasattr(t, 'hat'):
1048                 draw_body_part(t.hat, self.size.y - 5)
1049             safe_addstr(self.size.y - 1, start_x, '|        |')
1050
1051         def draw_help():
1052             content = "%s help\n\n%s\n\n" % (self.mode.short_desc,
1053                                              self.mode.help_intro)
1054             if len(self.mode.available_actions) > 0:
1055                 content += "Available actions:\n"
1056                 for action in self.mode.available_actions:
1057                     if action in action_tasks:
1058                         if action_tasks[action] not in self.game.tasks:
1059                             continue
1060                     if action == 'move_explorer':
1061                         action = 'move'
1062                     if action == 'move':
1063                         key = ','.join(self.movement_keys)
1064                     else:
1065                         key = self.keys[action]
1066                     content += '[%s] – %s\n' % (key, action_descriptions[action])
1067                 content += '\n'
1068             content += self.mode.list_available_modes(self)
1069             for i in range(self.size.y):
1070                 safe_addstr(i,
1071                             self.window_width * (not self.mode.has_input_prompt),
1072                             ' ' * self.window_width)
1073             lines = []
1074             for line in content.split('\n'):
1075                 lines += msg_into_lines_of_width(line, self.window_width)
1076             for i in range(len(lines)):
1077                 if i >= self.size.y:
1078                     break
1079                 safe_addstr(i,
1080                             self.window_width * (not self.mode.has_input_prompt),
1081                             lines[i])
1082
1083         def draw_screen():
1084             stdscr.clear()
1085             stdscr.bkgd(' ', curses.color_pair(1))
1086             recalc_input_lines()
1087             if self.mode.has_input_prompt:
1088                 draw_input()
1089             if self.mode.shows_info:
1090                 draw_info()
1091             else:
1092                 draw_history()
1093             draw_mode()
1094             if not self.mode.is_intro:
1095                 draw_stats()
1096                 draw_map()
1097             if self.show_help:
1098                 draw_help()
1099             if self.draw_face and self.mode.name in {'chat', 'play'}:
1100                 draw_face_popup()
1101
1102         def pick_selectable(task_name):
1103             try:
1104                 i = int(self.input_)
1105                 if i < 0 or i >= len(self.selectables):
1106                     self.log_msg('? invalid index, aborted')
1107                 else:
1108                     self.send('TASK:%s %s' % (task_name, self.selectables[i]))
1109             except ValueError:
1110                 self.log_msg('? invalid index, aborted')
1111             self.input_ = ''
1112             self.switch_mode('play')
1113
1114         def enter_ascii_art(command):
1115             if len(self.input_) != 6:
1116                 self.log_msg('? wrong input length, must be 6; try again')
1117                 return
1118             self.log_msg('  ' + self.input_)
1119             self.full_ascii_draw += self.input_
1120             self.ascii_draw_stage += 1
1121             if self.ascii_draw_stage < 3:
1122                 self.restore_input_values()
1123             else:
1124                 self.send('%s %s' % (command, quote(self.full_ascii_draw)))
1125                 self.full_ascii_draw = ""
1126                 self.ascii_draw_stage = 0
1127                 self.input_ = ""
1128                 self.switch_mode('edit')
1129
1130         action_descriptions = {
1131             'move': 'move',
1132             'flatten': 'flatten surroundings',
1133             'teleport': 'teleport',
1134             'take_thing': 'pick up thing',
1135             'drop_thing': 'drop thing',
1136             'toggle_map_mode': 'toggle map view',
1137             'toggle_tile_draw': 'toggle protection character drawing',
1138             'install': '(un-)install',
1139             'wear': '(un-)wear',
1140             'door': 'open/close',
1141             'consume': 'consume',
1142             'spin': 'spin',
1143         }
1144
1145         action_tasks = {
1146             'flatten': 'FLATTEN_SURROUNDINGS',
1147             'take_thing': 'PICK_UP',
1148             'drop_thing': 'DROP',
1149             'door': 'DOOR',
1150             'install': 'INSTALL',
1151             'wear': 'WEAR',
1152             'move': 'MOVE',
1153             'command': 'COMMAND',
1154             'consume': 'INTOXICATE',
1155             'spin': 'SPIN',
1156         }
1157
1158         curses.curs_set(False)  # hide cursor
1159         curses.start_color()
1160         self.set_default_colors()
1161         curses.init_pair(1, 1, 2)
1162         stdscr.timeout(10)
1163         reset_screen_size()
1164         self.explorer = YX(0, 0)
1165         self.input_ = ''
1166         input_prompt = '> '
1167         interval = datetime.timedelta(seconds=5)
1168         last_ping = datetime.datetime.now() - interval
1169         while True:
1170             if self.disconnected and self.force_instant_connect:
1171                 self.force_instant_connect = False
1172                 self.connect()
1173             now = datetime.datetime.now()
1174             if now - last_ping > interval:
1175                 if self.disconnected:
1176                     self.connect()
1177                 else:
1178                     self.send('PING')
1179                 last_ping = now
1180             if self.flash:
1181                 curses.flash()
1182                 self.flash = False
1183             if self.do_refresh:
1184                 draw_screen()
1185                 self.do_refresh = False
1186             while True:
1187                 try:
1188                     msg = self.queue.get(block=False)
1189                     handle_input(msg)
1190                 except queue.Empty:
1191                     break
1192             try:
1193                 key = stdscr.getkey()
1194                 self.do_refresh = True
1195             except curses.error:
1196                 continue
1197             keycode = None
1198             if len(key) == 1:
1199                 keycode = ord(key)
1200             self.show_help = False
1201             self.draw_face = False
1202             if key == 'KEY_RESIZE':
1203                 reset_screen_size()
1204             elif self.mode.has_input_prompt and key == 'KEY_BACKSPACE':
1205                 self.input_ = self.input_[:-1]
1206             elif (((not self.mode.is_intro) and keycode == 27)  # Escape
1207                   or (self.mode.has_input_prompt and key == '\n'
1208                       and self.input_ == ''\
1209                       and self.mode.name in {'chat', 'command_thing',
1210                                              'take_thing', 'drop_thing',
1211                                              'admin_enter'})):
1212                 if self.mode.name not in {'chat', 'play', 'study', 'edit'}:
1213                     self.log_msg('@ aborted')
1214                 self.switch_mode('play')
1215             elif self.mode.has_input_prompt and key == '\n' and self.input_ == '/help':
1216                 self.show_help = True
1217                 self.input_ = ""
1218                 self.restore_input_values()
1219             elif self.mode.has_input_prompt and key != '\n':  # Return key
1220                 self.input_ += key
1221                 max_length = self.window_width * self.size.y - len(input_prompt) - 1
1222                 if len(self.input_) > max_length:
1223                     self.input_ = self.input_[:max_length]
1224             elif key == self.keys['help'] and not self.mode.is_single_char_entry:
1225                 self.show_help = True
1226             elif self.mode.name == 'login' and key == '\n':
1227                 self.login_name = self.input_
1228                 self.send('LOGIN ' + quote(self.input_))
1229                 self.input_ = ""
1230             elif self.mode.name == 'enter_face' and key == '\n':
1231                 enter_ascii_art('PLAYER_FACE')
1232             elif self.mode.name == 'enter_hat' and key == '\n':
1233                 enter_ascii_art('PLAYER_HAT')
1234             elif self.mode.name == 'take_thing' and key == '\n':
1235                 pick_selectable('PICK_UP')
1236             elif self.mode.name == 'drop_thing' and key == '\n':
1237                 pick_selectable('DROP')
1238             elif self.mode.name == 'command_thing' and key == '\n':
1239                 self.send('TASK:COMMAND ' + quote(self.input_))
1240                 self.input_ = ""
1241             elif self.mode.name == 'control_pw_pw' and key == '\n':
1242                 if self.input_ == '':
1243                     self.log_msg('@ aborted')
1244                 else:
1245                     self.send('SET_MAP_CONTROL_PASSWORD ' + quote(self.tile_control_char) + ' ' + quote(self.input_))
1246                     self.log_msg('@ sent new password for protection character "%s"' % self.tile_control_char)
1247                 self.switch_mode('admin')
1248             elif self.mode.name == 'password' and key == '\n':
1249                 if self.input_ == '':
1250                     self.input_ = ' '
1251                 self.password = self.input_
1252                 self.switch_mode('edit')
1253             elif self.mode.name == 'admin_enter' and key == '\n':
1254                 self.send('BECOME_ADMIN ' + quote(self.input_))
1255                 self.switch_mode('play')
1256             elif self.mode.name == 'control_pw_type' and key == '\n':
1257                 if len(self.input_) != 1:
1258                     self.log_msg('@ entered non-single-char, therefore aborted')
1259                     self.switch_mode('admin')
1260                 else:
1261                     self.tile_control_char = self.input_
1262                     self.switch_mode('control_pw_pw')
1263             elif self.mode.name == 'admin_thing_protect' and key == '\n':
1264                 if len(self.input_) != 1:
1265                     self.log_msg('@ entered non-single-char, therefore aborted')
1266                 else:
1267                     self.send('THING_PROTECTION %s' % (quote(self.input_)))
1268                     self.log_msg('@ sent new protection character for thing')
1269                 self.switch_mode('admin')
1270             elif self.mode.name == 'control_tile_type' and key == '\n':
1271                 if len(self.input_) != 1:
1272                     self.log_msg('@ entered non-single-char, therefore aborted')
1273                     self.switch_mode('admin')
1274                 else:
1275                     self.tile_control_char = self.input_
1276                     self.switch_mode('control_tile_draw')
1277             elif self.mode.name == 'chat' and key == '\n':
1278                 if self.input_ == '':
1279                     continue
1280                 if self.input_[0] == '/':
1281                     if self.input_.startswith('/nick'):
1282                         tokens = self.input_.split(maxsplit=1)
1283                         if len(tokens) == 2:
1284                             self.send('NICK ' + quote(tokens[1]))
1285                         else:
1286                             self.log_msg('? need login name')
1287                     else:
1288                         self.log_msg('? unknown command')
1289                 else:
1290                     self.send('ALL ' + quote(self.input_))
1291                 self.input_ = ""
1292             elif self.mode.name == 'name_thing' and key == '\n':
1293                 if self.input_ == '':
1294                     self.input_ = ' '
1295                 self.send('THING_NAME %s %s' % (quote(self.input_),
1296                                                 quote(self.password)))
1297                 self.switch_mode('edit')
1298             elif self.mode.name == 'annotate' and key == '\n':
1299                 if self.input_ == '':
1300                     self.input_ = ' '
1301                 self.send('ANNOTATE %s %s %s' % (self.explorer, quote(self.input_),
1302                                                  quote(self.password)))
1303                 self.switch_mode('edit')
1304             elif self.mode.name == 'portal' and key == '\n':
1305                 if self.input_ == '':
1306                     self.input_ = ' '
1307                 self.send('PORTAL %s %s %s' % (self.explorer, quote(self.input_),
1308                                                quote(self.password)))
1309                 self.switch_mode('edit')
1310             elif self.mode.name == 'study':
1311                 if self.mode.mode_switch_on_key(self, key):
1312                     continue
1313                 elif key == self.keys['toggle_map_mode']:
1314                     self.toggle_map_mode()
1315                 elif key in self.movement_keys:
1316                     move_explorer(self.movement_keys[key])
1317             elif self.mode.name == 'play':
1318                 if self.mode.mode_switch_on_key(self, key):
1319                     continue
1320                 elif key == self.keys['door'] and task_action_on('door'):
1321                     self.send('TASK:DOOR')
1322                 elif key == self.keys['consume'] and task_action_on('consume'):
1323                     self.send('TASK:INTOXICATE')
1324                 elif key == self.keys['wear'] and task_action_on('wear'):
1325                     self.send('TASK:WEAR')
1326                 elif key == self.keys['spin'] and task_action_on('spin'):
1327                     self.send('TASK:SPIN')
1328                 elif key == self.keys['teleport']:
1329                     if self.game.player.position in self.game.portals:
1330                         self.host = self.game.portals[self.game.player.position]
1331                         self.reconnect()
1332                     else:
1333                         self.flash = True
1334                         self.log_msg('? not standing on portal')
1335                 elif key in self.movement_keys and task_action_on('move'):
1336                     self.send('TASK:MOVE ' + self.movement_keys[key])
1337             elif self.mode.name == 'write':
1338                 self.send('TASK:WRITE %s %s' % (key, quote(self.password)))
1339                 self.switch_mode('edit')
1340             elif self.mode.name == 'control_tile_draw':
1341                 if self.mode.mode_switch_on_key(self, key):
1342                     continue
1343                 elif key in self.movement_keys:
1344                     move_explorer(self.movement_keys[key])
1345                 elif key == self.keys['toggle_tile_draw']:
1346                     self.tile_draw = False if self.tile_draw else True
1347             elif self.mode.name == 'admin':
1348                 if self.mode.mode_switch_on_key(self, key):
1349                     continue
1350                 elif key in self.movement_keys and task_action_on('move'):
1351                     self.send('TASK:MOVE ' + self.movement_keys[key])
1352             elif self.mode.name == 'edit':
1353                 if self.mode.mode_switch_on_key(self, key):
1354                     continue
1355                 elif key == self.keys['flatten'] and task_action_on('flatten'):
1356                     self.send('TASK:FLATTEN_SURROUNDINGS ' + quote(self.password))
1357                 elif key == self.keys['install'] and task_action_on('install'):
1358                     self.send('TASK:INSTALL %s' % quote(self.password))
1359                 elif key == self.keys['toggle_map_mode']:
1360                     self.toggle_map_mode()
1361                 elif key in self.movement_keys and task_action_on('move'):
1362                     self.send('TASK:MOVE ' + self.movement_keys[key])
1363
1364 if len(sys.argv) != 2:
1365     raise ArgError('wrong number of arguments, need game host')
1366 host = sys.argv[1]
1367 TUI(host)