home · contact · privacy
b09dcc7e6548a3a1cd712684965b50f39dc5a5f2
[plomrogue2] / rogue_chat_curses.py
1 #!/usr/bin/env python3
2 import curses
3 import queue
4 import threading
5 from plomrogue.game import GameBase
6 from plomrogue.parser import Parser
7 from plomrogue.mapping import YX, MapGeometrySquare, MapGeometryHex
8 from plomrogue.things import ThingBase
9 from plomrogue.misc import quote
10 from plomrogue.errors import BrokenSocketConnection
11
12 from ws4py.client import WebSocketBaseClient
13 class WebSocketClient(WebSocketBaseClient):
14
15     def __init__(self, recv_handler, *args, **kwargs):
16         super().__init__(*args, **kwargs)
17         self.recv_handler = recv_handler
18         self.connect()
19
20     def received_message(self, message):
21         if message.is_text:
22             message = str(message)
23             self.recv_handler(message)
24
25     @property
26     def plom_closed(self):
27         return self.client_terminated
28
29 from plomrogue.io_tcp import PlomSocket
30 class PlomSocketClient(PlomSocket):
31
32     def __init__(self, recv_handler, url):
33         import socket
34         self.recv_handler = recv_handler
35         host, port = url.split(':')
36         super().__init__(socket.create_connection((host, port)))
37
38     def close(self):
39         self.socket.close()
40
41     def run(self):
42         import ssl
43         try:
44             for msg in self.recv():
45                 if msg == 'NEED_SSL':
46                     self.socket = ssl.wrap_socket(self.socket)
47                     continue
48                 self.recv_handler(msg)
49         except BrokenSocketConnection:
50             pass  # we assume socket will be known as dead by now
51
52 def cmd_TURN(game, n):
53     game.turn = n
54     game.things = []
55     game.portals = {}
56     game.turn_complete = False
57 cmd_TURN.argtypes = 'int:nonneg'
58
59 def cmd_LOGIN_OK(game):
60     game.tui.switch_mode('post_login_wait')
61     game.tui.send('GET_GAMESTATE')
62     game.tui.log_msg('@ welcome')
63 cmd_LOGIN_OK.argtypes = ''
64
65 def cmd_CHAT(game, msg):
66     game.tui.log_msg('# ' + msg)
67     game.tui.do_refresh = True
68 cmd_CHAT.argtypes = 'string'
69
70 def cmd_PLAYER_ID(game, player_id):
71     game.player_id = player_id
72 cmd_PLAYER_ID.argtypes = 'int:nonneg'
73
74 def cmd_THING_POS(game, thing_id, position):
75     t = game.get_thing(thing_id, True)
76     t.position = position
77 cmd_THING_POS.argtypes = 'int:nonneg yx_tuple:nonneg'
78
79 def cmd_THING_NAME(game, thing_id, name):
80     t = game.get_thing(thing_id, True)
81     t.name = name
82 cmd_THING_NAME.argtypes = 'int:nonneg string'
83
84 def cmd_MAP(game, geometry, size, content):
85     map_geometry_class = globals()['MapGeometry' + geometry]
86     game.map_geometry = map_geometry_class(size)
87     game.map_content = content
88     if type(game.map_geometry) == MapGeometrySquare:
89         game.tui.movement_keys = {
90             game.tui.keys['square_move_up']: 'UP',
91             game.tui.keys['square_move_left']: 'LEFT',
92             game.tui.keys['square_move_down']: 'DOWN',
93             game.tui.keys['square_move_right']: 'RIGHT',
94         }
95     elif type(game.map_geometry) == MapGeometryHex:
96         game.tui.movement_keys = {
97             game.tui.keys['hex_move_upleft']: 'UPLEFT',
98             game.tui.keys['hex_move_upright']: 'UPRIGHT',
99             game.tui.keys['hex_move_right']: 'RIGHT',
100             game.tui.keys['hex_move_downright']: 'DOWNRIGHT',
101             game.tui.keys['hex_move_downleft']: 'DOWNLEFT',
102             game.tui.keys['hex_move_left']: 'LEFT',
103         }
104 cmd_MAP.argtypes = 'string:map_geometry yx_tuple:pos string'
105
106 def cmd_MAP_CONTROL(game, content):
107     game.map_control_content = content
108 cmd_MAP_CONTROL.argtypes = 'string'
109
110 def cmd_GAME_STATE_COMPLETE(game):
111     game.info_db = {}
112     if game.tui.mode.name == 'post_login_wait':
113         game.tui.switch_mode('play')
114     if game.tui.mode.shows_info:
115         game.tui.query_info()
116     player = game.get_thing(game.player_id, False)
117     if player.position in game.portals:
118         game.tui.teleport_target_host = game.portals[player.position]
119         game.tui.switch_mode('teleport')
120     game.turn_complete = True
121     game.tui.do_refresh = True
122 cmd_GAME_STATE_COMPLETE.argtypes = ''
123
124 def cmd_PORTAL(game, position, msg):
125     game.portals[position] = msg
126 cmd_PORTAL.argtypes = 'yx_tuple:nonneg string'
127
128 def cmd_PLAY_ERROR(game, msg):
129     game.tui.flash()
130     game.tui.do_refresh = True
131 cmd_PLAY_ERROR.argtypes = 'string'
132
133 def cmd_GAME_ERROR(game, msg):
134     game.tui.log_msg('? game error: ' + msg)
135     game.tui.do_refresh = True
136 cmd_GAME_ERROR.argtypes = 'string'
137
138 def cmd_ARGUMENT_ERROR(game, msg):
139     game.tui.log_msg('? syntax error: ' + msg)
140     game.tui.do_refresh = True
141 cmd_ARGUMENT_ERROR.argtypes = 'string'
142
143 def cmd_ANNOTATION(game, position, msg):
144     game.info_db[position] = msg
145     if game.tui.mode.shows_info:
146         game.tui.do_refresh = True
147 cmd_ANNOTATION.argtypes = 'yx_tuple:nonneg string'
148
149 def cmd_TASKS(game, tasks_comma_separated):
150     game.tasks = tasks_comma_separated.split(',')
151 cmd_TASKS.argtypes = 'string'
152
153 def cmd_PONG(game):
154     pass
155 cmd_PONG.argtypes = ''
156
157 class Game(GameBase):
158     thing_type = ThingBase
159     turn_complete = False
160     tasks = {}
161
162     def __init__(self, *args, **kwargs):
163         super().__init__(*args, **kwargs)
164         self.register_command(cmd_LOGIN_OK)
165         self.register_command(cmd_PONG)
166         self.register_command(cmd_CHAT)
167         self.register_command(cmd_PLAYER_ID)
168         self.register_command(cmd_TURN)
169         self.register_command(cmd_THING_POS)
170         self.register_command(cmd_THING_NAME)
171         self.register_command(cmd_MAP)
172         self.register_command(cmd_MAP_CONTROL)
173         self.register_command(cmd_PORTAL)
174         self.register_command(cmd_ANNOTATION)
175         self.register_command(cmd_GAME_STATE_COMPLETE)
176         self.register_command(cmd_ARGUMENT_ERROR)
177         self.register_command(cmd_GAME_ERROR)
178         self.register_command(cmd_PLAY_ERROR)
179         self.register_command(cmd_TASKS)
180         self.map_content = ''
181         self.player_id = -1
182         self.info_db = {}
183         self.portals = {}
184
185     def get_string_options(self, string_option_type):
186         if string_option_type == 'map_geometry':
187             return ['Hex', 'Square']
188         return None
189
190     def get_command(self, command_name):
191         from functools import partial
192         f = partial(self.commands[command_name], self)
193         f.argtypes = self.commands[command_name].argtypes
194         return f
195
196 class TUI:
197
198     class Mode:
199
200         def __init__(self, name, help_intro, has_input_prompt=False,
201                      shows_info=False, is_intro = False):
202             self.name = name
203             self.has_input_prompt = has_input_prompt
204             self.shows_info = shows_info
205             self.is_intro = is_intro
206             self.help_intro = help_intro
207
208     def __init__(self, host):
209         import os
210         import json
211         self.host = host
212         self.mode_play = self.Mode('play', 'This mode allows you to interact with the map.')
213         self.mode_study = self.Mode('study', '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 (unless obscured by this help screen here, which you can disappear with any key).', shows_info=True)
214         self.mode_edit = self.Mode('edit', 'This mode allows you to change the map tile you currently stand on (if your map editing password authorizes you so).  Just enter any printable ASCII character to imprint it on the ground below you.')
215         self.mode_annotate = self.Mode('annotate', 'This mode allows you to add/edit a comment on the tile you are currently standing on (provided your map editing password authorizes you so).  Hit Return to leave.', has_input_prompt=True, shows_info=True)
216         self.mode_portal = self.Mode('portal', 'This mode allows you to imprint/edit/remove a teleportation target on the ground you are currently standing on (provided your map 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.', has_input_prompt=True, shows_info=True)
217         self.mode_chat = self.Mode('chat', '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 to all users.  Lines that start with a "/" are used for commands like:', has_input_prompt=True)
218         self.mode_waiting_for_server = self.Mode('waiting_for_server', 'Waiting for a server response.', is_intro=True)
219         self.mode_login = self.Mode('login', 'Pick your player name.', has_input_prompt=True, is_intro=True)
220         self.mode_post_login_wait = self.Mode('post_login_wait', 'Waiting for a server response.', is_intro=True)
221         self.mode_teleport = self.Mode('teleport', 'Follow the instructions to re-connect and log-in to another server, or enter anything else to abort.', has_input_prompt=True)
222         self.mode_password = self.Mode('password', 'This mode allows you to change the password that you send to authorize yourself for editing password-protected map tiles.  Hit return to confirm and leave.', has_input_prompt=True)
223         self.game = Game()
224         self.game.tui = self
225         self.parser = Parser(self.game)
226         self.log = []
227         self.do_refresh = True
228         self.queue = queue.Queue()
229         self.login_name = None
230         self.map_mode = 'terrain'
231         self.password = 'foo'
232         self.switch_mode('waiting_for_server')
233         self.keys = {
234             'switch_to_chat': 't',
235             'switch_to_play': 'p',
236             'switch_to_password': 'p',
237             'switch_to_annotate': 'm',
238             'switch_to_portal': 'P',
239             'switch_to_study': '?',
240             'switch_to_edit': 'm',
241             'flatten': 'F',
242             'toggle_map_mode': 'M',
243             'hex_move_upleft': 'w',
244             'hex_move_upright': 'e',
245             'hex_move_right': 'd',
246             'hex_move_downright': 'x',
247             'hex_move_downleft': 'y',
248             'hex_move_left': 'a',
249             'square_move_up': 'w',
250             'square_move_left': 'a',
251             'square_move_down': 's',
252             'square_move_right': 'd',
253         }
254         if os.path.isfile('config.json'):
255             with open('config.json', 'r') as f:
256                 keys_conf = json.loads(f.read())
257             for k in keys_conf:
258                 self.keys[k] = keys_conf[k]
259         self.show_help = False
260         curses.wrapper(self.loop)
261
262     def flash(self):
263         curses.flash()
264
265     def send(self, msg):
266         try:
267             if hasattr(self.socket, 'plom_closed') and self.socket.plom_closed:
268                 raise BrokenSocketConnection
269             self.socket.send(msg)
270         except (BrokenPipeError, BrokenSocketConnection):
271             self.log_msg('@ server disconnected :(')
272             self.do_refresh = True
273
274     def log_msg(self, msg):
275         self.log += [msg]
276         if len(self.log) > 100:
277             self.log = self.log[-100:]
278
279     def query_info(self):
280         self.send('GET_ANNOTATION ' + str(self.explorer))
281
282     def restore_input_values(self):
283         if self.mode.name == 'annotate' and self.explorer in self.game.info_db:
284             info = self.game.info_db[self.explorer]
285             if info != '(none)':
286                 self.input_ = info
287         elif self.mode.name == 'portal' and self.explorer in self.game.portals:
288             self.input_ = self.game.portals[self.explorer]
289         elif self.mode.name == 'password':
290             self.input_ = self.password
291
292     def switch_mode(self, mode_name, keep_position = False):
293         self.map_mode = 'terrain'
294         self.mode = getattr(self, 'mode_' + mode_name)
295         if self.mode.shows_info and not keep_position:
296             player = self.game.get_thing(self.game.player_id, False)
297             self.explorer = YX(player.position.y, player.position.x)
298         if self.mode.name == 'waiting_for_server':
299             self.log_msg('@ waiting for server …')
300         if self.mode.name == 'edit':
301             self.show_help = True
302         elif self.mode.name == 'login':
303             if self.login_name:
304                 self.send('LOGIN ' + quote(self.login_name))
305             else:
306                 self.log_msg('@ enter username')
307         elif self.mode.name == 'teleport':
308             self.log_msg("@ May teleport to %s" % (self.teleport_target_host)),
309             self.log_msg("@ Enter 'YES!' to enthusiastically affirm.");
310         self.restore_input_values()
311
312     def loop(self, stdscr):
313         import time
314         import datetime
315
316         def safe_addstr(y, x, line):
317             if y < self.size.y - 1 or x + len(line) < self.size.x:
318                 stdscr.addstr(y, x, line)
319             else:  # workaround to <https://stackoverflow.com/q/7063128>
320                 cut_i = self.size.x - x - 1
321                 cut = line[:cut_i]
322                 last_char = line[cut_i]
323                 stdscr.addstr(y, self.size.x - 2, last_char)
324                 stdscr.insstr(y, self.size.x - 2, ' ')
325                 stdscr.addstr(y, x, cut)
326
327         def connect():
328
329             def handle_recv(msg):
330                 if msg == 'BYE':
331                     self.socket.close()
332                 else:
333                     self.queue.put(msg)
334
335             socket_client_class = PlomSocketClient
336             if self.host.startswith('ws://') or self.host.startswith('wss://'):
337                 socket_client_class = WebSocketClient
338             while True:
339                 try:
340                     self.socket = socket_client_class(handle_recv, self.host)
341                     self.socket_thread = threading.Thread(target=self.socket.run)
342                     self.socket_thread.start()
343                     self.socket.send('TASKS')
344                     self.switch_mode('login')
345                     return
346                 except ConnectionRefusedError:
347                     self.log_msg('@ server connect failure, trying again …')
348                     draw_screen()
349                     stdscr.refresh()
350                     time.sleep(1)
351
352         def reconnect():
353             self.send('QUIT')
354             time.sleep(0.1)  # FIXME necessitated by some some strange SSL race
355                              # conditions with ws4py, find out what exactly
356             self.switch_mode('waiting_for_server')
357             connect()
358
359         def handle_input(msg):
360             command, args = self.parser.parse(msg)
361             command(*args)
362
363         def msg_into_lines_of_width(msg, width):
364             chunk = ''
365             lines = []
366             x = 0
367             for i in range(len(msg)):
368                 if x >= width or msg[i] == "\n":
369                     lines += [chunk]
370                     chunk = ''
371                     x = 0
372                 if msg[i] != "\n":
373                     chunk += msg[i]
374                 x += 1
375             lines += [chunk]
376             return lines
377
378         def reset_screen_size():
379             self.size = YX(*stdscr.getmaxyx())
380             self.size = self.size - YX(self.size.y % 4, 0)
381             self.size = self.size - YX(0, self.size.x % 4)
382             self.window_width = int(self.size.x / 2)
383
384         def recalc_input_lines():
385             if not self.mode.has_input_prompt:
386                 self.input_lines = []
387             else:
388                 self.input_lines = msg_into_lines_of_width(input_prompt + self.input_,
389                                                            self.window_width)
390
391         def move_explorer(direction):
392             target = self.game.map_geometry.move(self.explorer, direction)
393             if target:
394                 self.explorer = target
395                 self.query_info()
396             else:
397                 self.flash()
398
399         def draw_history():
400             lines = []
401             for line in self.log:
402                 lines += msg_into_lines_of_width(line, self.window_width)
403             lines.reverse()
404             height_header = 2
405             max_y = self.size.y - len(self.input_lines)
406             for i in range(len(lines)):
407                 if (i >= max_y - height_header):
408                     break
409                 safe_addstr(max_y - i - 1, self.window_width, lines[i])
410
411         def draw_info():
412             if not self.game.turn_complete:
413                 return
414             pos_i = self.explorer.y * self.game.map_geometry.size.x + self.explorer.x
415             info = 'TERRAIN: %s\n' % self.game.map_content[pos_i]
416             for t in self.game.things:
417                 if t.position == self.explorer:
418                     info += 'PLAYER @: %s\n' % t.name
419             if self.explorer in self.game.portals:
420                 info += 'PORTAL: ' + self.game.portals[self.explorer] + '\n'
421             else:
422                 info += 'PORTAL: (none)\n'
423             if self.explorer in self.game.info_db:
424                 info += 'ANNOTATION: ' + self.game.info_db[self.explorer]
425             else:
426                 info += 'ANNOTATION: waiting …'
427             lines = msg_into_lines_of_width(info, self.window_width)
428             height_header = 2
429             for i in range(len(lines)):
430                 y = height_header + i
431                 if y >= self.size.y - len(self.input_lines):
432                     break
433                 safe_addstr(y, self.window_width, lines[i])
434
435         def draw_input():
436             y = self.size.y - len(self.input_lines)
437             for i in range(len(self.input_lines)):
438                 safe_addstr(y, self.window_width, self.input_lines[i])
439                 y += 1
440
441         def draw_turn():
442             if not self.game.turn_complete:
443                 return
444             safe_addstr(0, self.window_width, 'TURN: ' + str(self.game.turn))
445
446         def draw_mode():
447             help = "hit [%s] for help" % self.keys['help']
448             if self.mode.has_input_prompt:
449                 help = "enter /help for help"
450             safe_addstr(1, self.window_width, 'MODE: %s – %s' % (self.mode.name, help))
451
452         def draw_map():
453             if not self.game.turn_complete:
454                 return
455             map_lines_split = []
456             map_content = self.game.map_content
457             if self.map_mode == 'control':
458                 map_content = self.game.map_control_content
459             for y in range(self.game.map_geometry.size.y):
460                 start = self.game.map_geometry.size.x * y
461                 end = start + self.game.map_geometry.size.x
462                 map_lines_split += [list(map_content[start:end])]
463             if self.map_mode == 'terrain':
464                 for t in self.game.things:
465                     map_lines_split[t.position.y][t.position.x] = '@'
466             if self.mode.shows_info:
467                 map_lines_split[self.explorer.y][self.explorer.x] = '?'
468             map_lines = []
469             if type(self.game.map_geometry) == MapGeometryHex:
470                 indent = 0
471                 for line in map_lines_split:
472                     map_lines += [indent*' ' + ' '.join(line)]
473                     indent = 0 if indent else 1
474             else:
475                 for line in map_lines_split:
476                     map_lines += [' '.join(line)]
477             window_center = YX(int(self.size.y / 2),
478                                int(self.window_width / 2))
479             player = self.game.get_thing(self.game.player_id, False)
480             center = player.position
481             if self.mode.shows_info:
482                 center = self.explorer
483             center = YX(center.y, center.x * 2)
484             offset = center - window_center
485             if type(self.game.map_geometry) == MapGeometryHex and offset.y % 2:
486                 offset += YX(0, 1)
487             term_y = max(0, -offset.y)
488             term_x = max(0, -offset.x)
489             map_y = max(0, offset.y)
490             map_x = max(0, offset.x)
491             while (term_y < self.size.y and map_y < self.game.map_geometry.size.y):
492                 to_draw = map_lines[map_y][map_x:self.window_width + offset.x]
493                 safe_addstr(term_y, term_x, to_draw)
494                 term_y += 1
495                 map_y += 1
496
497         def draw_help():
498             content = "%s mode help (hit any key to disappear)\n\n%s\n\n" % (self.mode.name,
499                                                             self.mode.help_intro)
500             if self.mode == self.mode_play:
501                 content += "Available actions:\n"
502                 if 'MOVE' in self.game.tasks:
503                     content += "[%s] – move player\n" % ','.join(self.movement_keys)
504                 if 'FLATTEN_SURROUNDINGS' in self.game.tasks:
505                     content += "[%s] – flatten player's surroundings\n" % self.keys['flatten']
506                 content += 'Other modes available from here:\n'
507                 content += '[%s] – terrain edit mode\n' % self.keys['switch_to_edit']
508                 content += '[%s] – terrain password edit mode\n' % self.keys['switch_to_password']
509                 content += '[%s] – chat mode\n' % self.keys['switch_to_chat']
510                 content += '[%s] – study mode\n' % self.keys['switch_to_study']
511             elif self.mode == self.mode_study:
512                 content += 'Available actions:\n'
513                 content += '[%s] – move question mark\n' % ','.join(self.movement_keys)
514                 content += '[%s] – toggle view between terrain, and password protection areas\n' % self.keys['toggle_map_mode']
515                 content += '\n\nOther modes available from here:'
516                 content += '[%s] – chat mode\n' % self.keys['switch_to_chat']
517                 content += '[%s] – play mode\n' % self.keys['switch_to_play']
518                 content += '[%s] – portal mode\n' % self.keys['switch_to_portal']
519                 content += '[%s] – annotation mode\n' % self.keys['switch_to_annotate']
520             elif self.mode == self.mode_chat:
521                 content += '/nick NAME – re-name yourself to NAME\n'
522                 content += '/msg USER TEXT – send TEXT to USER\n'
523                 content += '/%s or /play – switch to play mode\n' % self.keys['switch_to_play']
524                 content += '/%s or /study – switch to study mode\n' % self.keys['switch_to_study']
525             for i in range(self.size.y):
526                 safe_addstr(i,
527                             self.window_width * (not self.mode.has_input_prompt),
528                             ' '*self.window_width)
529             lines = []
530             for line in content.split('\n'):
531                 lines += msg_into_lines_of_width(line, self.window_width)
532             for i in range(len(lines)):
533                 if i >= self.size.y:
534                     break
535                 safe_addstr(i,
536                             self.window_width * (not self.mode.has_input_prompt),
537                             lines[i])
538
539         def draw_screen():
540             stdscr.clear()
541             if self.mode.has_input_prompt:
542                 recalc_input_lines()
543                 draw_input()
544             if self.mode.shows_info:
545                 draw_info()
546             else:
547                 draw_history()
548             draw_mode()
549             if not self.mode.is_intro:
550                 draw_turn()
551                 draw_map()
552             if self.show_help:
553                 draw_help()
554
555         curses.curs_set(False)  # hide cursor
556         curses.use_default_colors();
557         stdscr.timeout(10)
558         reset_screen_size()
559         self.explorer = YX(0, 0)
560         self.input_ = ''
561         input_prompt = '> '
562         connect()
563         last_ping = datetime.datetime.now()
564         interval = datetime.timedelta(seconds=30)
565         while True:
566             now = datetime.datetime.now()
567             if now - last_ping > interval:
568                 self.send('PING')
569                 last_ping = now
570             if self.do_refresh:
571                 draw_screen()
572                 self.do_refresh = False
573             while True:
574                 try:
575                     msg = self.queue.get(block=False)
576                     handle_input(msg)
577                 except queue.Empty:
578                     break
579             try:
580                 key = stdscr.getkey()
581                 self.do_refresh = True
582             except curses.error:
583                 continue
584             self.show_help = False
585             if key == 'KEY_RESIZE':
586                 reset_screen_size()
587             elif self.mode.has_input_prompt and key == 'KEY_BACKSPACE':
588                 self.input_ = self.input_[:-1]
589             elif self.mode.has_input_prompt and key == '\n' and self.input_ == '/help':
590                 self.show_help = True
591                 self.input_ = ""
592                 self.restore_input_values()
593             elif self.mode.has_input_prompt and key != '\n':  # Return key
594                 self.input_ += key
595                 max_length = self.window_width * self.size.y - len(input_prompt) - 1
596                 if len(self.input_) > max_length:
597                     self.input_ = self.input_[:max_length]
598             elif key == self.keys['help'] and self.mode != self.mode_edit:
599                 self.show_help = True
600             elif self.mode == self.mode_login and key == '\n':
601                 self.login_name = self.input_
602                 self.send('LOGIN ' + quote(self.input_))
603                 self.input_ = ""
604             elif self.mode == self.mode_password and key == '\n':
605                 if self.input_ == '':
606                     self.input_ = ' '
607                 self.password = self.input_
608                 self.input_ = ""
609                 self.switch_mode('play')
610             elif self.mode == self.mode_chat and key == '\n':
611                 if self.input_[0] == '/':
612                     if self.input_ in {'/' + self.keys['switch_to_play'], '/play'}:
613                         self.switch_mode('play')
614                     elif self.input_ in {'/' + self.keys['switch_to_study'], '/study'}:
615                         self.switch_mode('study')
616                     elif self.input_ == '/reconnect':
617                         reconnect()
618                     elif self.input_.startswith('/nick'):
619                         tokens = self.input_.split(maxsplit=1)
620                         if len(tokens) == 2:
621                             self.send('NICK ' + quote(tokens[1]))
622                         else:
623                             self.log_msg('? need login name')
624                     elif self.input_.startswith('/msg'):
625                         tokens = self.input_.split(maxsplit=2)
626                         if len(tokens) == 3:
627                             self.send('QUERY %s %s' % (quote(tokens[1]),
628                                                               quote(tokens[2])))
629                         else:
630                             self.log_msg('? need message target and message')
631                     else:
632                         self.log_msg('? unknown command')
633                 else:
634                     self.send('ALL ' + quote(self.input_))
635                 self.input_ = ""
636             elif self.mode == self.mode_annotate and key == '\n':
637                 if self.input_ == '':
638                     self.input_ = ' '
639                 self.send('ANNOTATE %s %s %s' % (self.explorer, quote(self.input_),
640                                                  quote(self.password))
641                 self.input_ = ""
642                 self.switch_mode('study', keep_position=True)
643             elif self.mode == self.mode_portal and key == '\n':
644                 if self.input_ == '':
645                     self.input_ = ' '
646                 self.send('PORTAL %s %s %s' % (self.explorer, quote(self.input_),
647                                                quote(self.password)))
648                 self.input_ = ""
649                 self.switch_mode('study', keep_position=True)
650             elif self.mode == self.mode_teleport and key == '\n':
651                 if self.input_ == 'YES!':
652                     self.host = self.teleport_target_host
653                     reconnect()
654                 else:
655                     self.log_msg('@ teleport aborted')
656                     self.switch_mode('play')
657                 self.input_ = ''
658             elif self.mode == self.mode_study:
659                 if key == self.keys['switch_to_chat']:
660                     self.switch_mode('chat')
661                 elif key == self.keys['switch_to_play']:
662                     self.switch_mode('play')
663                 elif key == self.keys['switch_to_annotate']:
664                     self.switch_mode('annotate', keep_position=True)
665                 elif key == self.keys['switch_to_portal']:
666                     self.switch_mode('portal', keep_position=True)
667                 elif key == self.keys['toggle_map_mode']:
668                     if self.map_mode == 'terrain':
669                         self.map_mode = 'control'
670                     else:
671                         self.map_mode = 'terrain'
672                 elif key in self.movement_keys:
673                     move_explorer(self.movement_keys[key])
674             elif self.mode == self.mode_play:
675                 if key == self.keys['switch_to_chat']:
676                     self.switch_mode('chat')
677                 elif key == self.keys['switch_to_study']:
678                     self.switch_mode('study')
679                 elif key == self.keys['switch_to_password']:
680                     self.switch_mode('password')
681                 if key == self.keys['switch_to_edit'] and\
682                    'WRITE' in self.game.tasks:
683                     self.switch_mode('edit')
684                 elif key == self.keys['flatten'] and\
685                      'FLATTEN_SURROUNDINGS' in self.game.tasks:
686                     self.send('TASK:FLATTEN_SURROUNDINGS ' + quote(self.password))
687                 elif key in self.movement_keys and 'MOVE' in self.game.tasks:
688                     self.send('TASK:MOVE ' + self.movement_keys[key])
689             elif self.mode == self.mode_edit:
690                 self.send('TASK:WRITE %s %s' % (key, quote(self.password)))
691                 self.switch_mode('play')
692
693 TUI('localhost:5000')