home · contact · privacy
Improve help system.
[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 terrain 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.  Hit Return to leave.', has_input_prompt=True, shows_info=True)
216         self.mode_portal = self.Mode('portal', 'This mode imprints/edits/removes a teleportation target on the ground you are currently standing on.  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 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 switch_mode(self, mode_name, keep_position = False):
283         self.map_mode = 'terrain'
284         self.mode = getattr(self, 'mode_' + mode_name)
285         if self.mode.shows_info and not keep_position:
286             player = self.game.get_thing(self.game.player_id, False)
287             self.explorer = YX(player.position.y, player.position.x)
288         if self.mode.name == 'waiting_for_server':
289             self.log_msg('@ waiting for server …')
290         if self.mode.name == 'edit':
291             self.show_help = True
292         elif self.mode.name == 'login':
293             if self.login_name:
294                 self.send('LOGIN ' + quote(self.login_name))
295             else:
296                 self.log_msg('@ enter username')
297         elif self.mode.name == 'teleport':
298             self.log_msg("@ May teleport to %s" % (self.teleport_target_host)),
299             self.log_msg("@ Enter 'YES!' to enthusiastically affirm.");
300         elif self.mode.name == 'annotate' and self.explorer in self.game.info_db:
301             info = self.game.info_db[self.explorer]
302             if info != '(none)':
303                 self.input_ = info
304         elif self.mode.name == 'portal' and self.explorer in self.game.portals:
305             self.input_ = self.game.portals[self.explorer]
306         elif self.mode.name == 'password':
307             self.input_ = self.password
308
309     def loop(self, stdscr):
310         import time
311         import datetime
312
313         def safe_addstr(y, x, line):
314             if y < self.size.y - 1 or x + len(line) < self.size.x:
315                 stdscr.addstr(y, x, line)
316             else:  # workaround to <https://stackoverflow.com/q/7063128>
317                 cut_i = self.size.x - x - 1
318                 cut = line[:cut_i]
319                 last_char = line[cut_i]
320                 stdscr.addstr(y, self.size.x - 2, last_char)
321                 stdscr.insstr(y, self.size.x - 2, ' ')
322                 stdscr.addstr(y, x, cut)
323
324         def connect():
325
326             def handle_recv(msg):
327                 if msg == 'BYE':
328                     self.socket.close()
329                 else:
330                     self.queue.put(msg)
331
332             socket_client_class = PlomSocketClient
333             if self.host.startswith('ws://') or self.host.startswith('wss://'):
334                 socket_client_class = WebSocketClient
335             while True:
336                 try:
337                     self.socket = socket_client_class(handle_recv, self.host)
338                     self.socket_thread = threading.Thread(target=self.socket.run)
339                     self.socket_thread.start()
340                     self.socket.send('TASKS')
341                     self.switch_mode('login')
342                     return
343                 except ConnectionRefusedError:
344                     self.log_msg('@ server connect failure, trying again …')
345                     draw_screen()
346                     stdscr.refresh()
347                     time.sleep(1)
348
349         def reconnect():
350             self.send('QUIT')
351             time.sleep(0.1)  # FIXME necessitated by some some strange SSL race
352                              # conditions with ws4py, find out what exactly
353             self.switch_mode('waiting_for_server')
354             connect()
355
356         def handle_input(msg):
357             command, args = self.parser.parse(msg)
358             command(*args)
359
360         def msg_into_lines_of_width(msg, width):
361             chunk = ''
362             lines = []
363             x = 0
364             for i in range(len(msg)):
365                 if x >= width or msg[i] == "\n":
366                     lines += [chunk]
367                     chunk = ''
368                     x = 0
369                 if msg[i] != "\n":
370                     chunk += msg[i]
371                 x += 1
372             lines += [chunk]
373             return lines
374
375         def reset_screen_size():
376             self.size = YX(*stdscr.getmaxyx())
377             self.size = self.size - YX(self.size.y % 4, 0)
378             self.size = self.size - YX(0, self.size.x % 4)
379             self.window_width = int(self.size.x / 2)
380
381         def recalc_input_lines():
382             if not self.mode.has_input_prompt:
383                 self.input_lines = []
384             else:
385                 self.input_lines = msg_into_lines_of_width(input_prompt + self.input_,
386                                                            self.window_width)
387
388         def move_explorer(direction):
389             target = self.game.map_geometry.move(self.explorer, direction)
390             if target:
391                 self.explorer = target
392                 self.query_info()
393             else:
394                 self.flash()
395
396         def draw_history():
397             lines = []
398             for line in self.log:
399                 lines += msg_into_lines_of_width(line, self.window_width)
400             lines.reverse()
401             height_header = 2
402             max_y = self.size.y - len(self.input_lines)
403             for i in range(len(lines)):
404                 if (i >= max_y - height_header):
405                     break
406                 safe_addstr(max_y - i - 1, self.window_width, lines[i])
407
408         def draw_info():
409             if not self.game.turn_complete:
410                 return
411             pos_i = self.explorer.y * self.game.map_geometry.size.x + self.explorer.x
412             info = 'TERRAIN: %s\n' % self.game.map_content[pos_i]
413             for t in self.game.things:
414                 if t.position == self.explorer:
415                     info += 'PLAYER @: %s\n' % t.name
416             if self.explorer in self.game.portals:
417                 info += 'PORTAL: ' + self.game.portals[self.explorer] + '\n'
418             else:
419                 info += 'PORTAL: (none)\n'
420             if self.explorer in self.game.info_db:
421                 info += 'ANNOTATION: ' + self.game.info_db[self.explorer]
422             else:
423                 info += 'ANNOTATION: waiting …'
424             lines = msg_into_lines_of_width(info, self.window_width)
425             height_header = 2
426             for i in range(len(lines)):
427                 y = height_header + i
428                 if y >= self.size.y - len(self.input_lines):
429                     break
430                 safe_addstr(y, self.window_width, lines[i])
431
432         def draw_input():
433             y = self.size.y - len(self.input_lines)
434             for i in range(len(self.input_lines)):
435                 safe_addstr(y, self.window_width, self.input_lines[i])
436                 y += 1
437
438         def draw_turn():
439             if not self.game.turn_complete:
440                 return
441             safe_addstr(0, self.window_width, 'TURN: ' + str(self.game.turn))
442
443         def draw_mode():
444             help = "hit [%s] for help" % self.keys['help']
445             if self.mode.has_input_prompt:
446                 help = "enter /help for help"
447             safe_addstr(1, self.window_width, 'MODE: %s – %s' % (self.mode.name, help))
448
449         def draw_map():
450             if not self.game.turn_complete:
451                 return
452             map_lines_split = []
453             map_content = self.game.map_content
454             if self.map_mode == 'control':
455                 map_content = self.game.map_control_content
456             for y in range(self.game.map_geometry.size.y):
457                 start = self.game.map_geometry.size.x * y
458                 end = start + self.game.map_geometry.size.x
459                 map_lines_split += [list(map_content[start:end])]
460             if self.map_mode == 'terrain':
461                 for t in self.game.things:
462                     map_lines_split[t.position.y][t.position.x] = '@'
463             if self.mode.shows_info:
464                 map_lines_split[self.explorer.y][self.explorer.x] = '?'
465             map_lines = []
466             if type(self.game.map_geometry) == MapGeometryHex:
467                 indent = 0
468                 for line in map_lines_split:
469                     map_lines += [indent*' ' + ' '.join(line)]
470                     indent = 0 if indent else 1
471             else:
472                 for line in map_lines_split:
473                     map_lines += [' '.join(line)]
474             window_center = YX(int(self.size.y / 2),
475                                int(self.window_width / 2))
476             player = self.game.get_thing(self.game.player_id, False)
477             center = player.position
478             if self.mode.shows_info:
479                 center = self.explorer
480             center = YX(center.y, center.x * 2)
481             offset = center - window_center
482             if type(self.game.map_geometry) == MapGeometryHex and offset.y % 2:
483                 offset += YX(0, 1)
484             term_y = max(0, -offset.y)
485             term_x = max(0, -offset.x)
486             map_y = max(0, offset.y)
487             map_x = max(0, offset.x)
488             while (term_y < self.size.y and map_y < self.game.map_geometry.size.y):
489                 to_draw = map_lines[map_y][map_x:self.window_width + offset.x]
490                 safe_addstr(term_y, term_x, to_draw)
491                 term_y += 1
492                 map_y += 1
493
494         def draw_help():
495             content = "%s mode help (hit any key to disappear)\n\n%s\n\n" % (self.mode.name,
496                                                             self.mode.help_intro)
497             if self.mode == self.mode_play:
498                 content += "Available actions:\n"
499                 if 'MOVE' in self.game.tasks:
500                     content += "[%s] – move player\n" % ','.join(self.movement_keys)
501                 if 'FLATTEN_SURROUNDINGS' in self.game.tasks:
502                     content += "[%s] – flatten player's surroundings\n" % self.keys['flatten']
503                 content += 'Other modes available from here:\n'
504                 content += '[%s] – terrain edit mode\n' % self.keys['switch_to_edit']
505                 content += '[%s] – terrain password edit mode\n' % self.keys['switch_to_password']
506                 content += '[%s] – chat mode\n' % self.keys['switch_to_chat']
507                 content += '[%s] – study mode\n' % self.keys['switch_to_study']
508             elif self.mode == self.mode_study:
509                 content += 'Available actions:\n'
510                 content += '[%s] – move question mark\n' % ','.join(self.movement_keys)
511                 content += '[%s] – toggle view between terrain, and password protection areas\n' % self.keys['toggle_map_mode']
512                 content += '\n\nOther modes available from here:'
513                 content += '[%s] – chat mode\n' % self.keys['switch_to_chat']
514                 content += '[%s] – play mode\n' % self.keys['switch_to_play']
515                 content += '[%s] – portal mode\n' % self.keys['switch_to_portal']
516                 content += '[%s] – annotation mode\n' % self.keys['switch_to_annotate']
517             elif self.mode == self.mode_chat:
518                 content += '/nick NAME – re-name yourself to NAME\n'
519                 content += '/msg USER TEXT – send TEXT to USER\n'
520                 content += '/%s or /play – switch to play mode\n' % self.keys['switch_to_play']
521                 content += '/%s or /study – switch to study mode\n' % self.keys['switch_to_study']
522             for i in range(self.size.y):
523                 safe_addstr(i,
524                             self.window_width * (not self.mode.has_input_prompt),
525                             ' '*self.window_width)
526             lines = []
527             for line in content.split('\n'):
528                 lines += msg_into_lines_of_width(line, self.window_width)
529             for i in range(len(lines)):
530                 if i >= self.size.y:
531                     break
532                 safe_addstr(i,
533                             self.window_width * (not self.mode.has_input_prompt),
534                             lines[i])
535
536         def draw_screen():
537             stdscr.clear()
538             if self.mode.has_input_prompt:
539                 recalc_input_lines()
540                 draw_input()
541             if self.mode.shows_info:
542                 draw_info()
543             else:
544                 draw_history()
545             draw_mode()
546             if not self.mode.is_intro:
547                 draw_turn()
548                 draw_map()
549             if self.show_help:
550                 draw_help()
551
552         curses.curs_set(False)  # hide cursor
553         curses.use_default_colors();
554         stdscr.timeout(10)
555         reset_screen_size()
556         self.explorer = YX(0, 0)
557         self.input_ = ''
558         input_prompt = '> '
559         connect()
560         last_ping = datetime.datetime.now()
561         interval = datetime.timedelta(seconds=30)
562         while True:
563             now = datetime.datetime.now()
564             if now - last_ping > interval:
565                 self.send('PING')
566                 last_ping = now
567             if self.do_refresh:
568                 draw_screen()
569                 self.do_refresh = False
570             while True:
571                 try:
572                     msg = self.queue.get(block=False)
573                     handle_input(msg)
574                 except queue.Empty:
575                     break
576             try:
577                 key = stdscr.getkey()
578                 self.do_refresh = True
579             except curses.error:
580                 continue
581             self.show_help = False
582             if key == 'KEY_RESIZE':
583                 reset_screen_size()
584             elif self.mode.has_input_prompt and key == 'KEY_BACKSPACE':
585                 self.input_ = self.input_[:-1]
586             elif self.mode.has_input_prompt and key == '\n' and self.input_ == '/help':
587                 self.show_help = True
588                 self.input_ = ""
589             elif self.mode.has_input_prompt and key != '\n':  # Return key
590                 self.input_ += key
591                 max_length = self.window_width * self.size.y - len(input_prompt) - 1
592                 if len(self.input_) > max_length:
593                     self.input_ = self.input_[:max_length]
594             elif key == self.keys['help'] and self.mode != self.mode_edit:
595                 self.show_help = True
596             elif self.mode == self.mode_login and key == '\n':
597                 self.login_name = self.input_
598                 self.send('LOGIN ' + quote(self.input_))
599                 self.input_ = ""
600             elif self.mode == self.mode_password and key == '\n':
601                 if self.input_ == '':
602                     self.input_ = ' '
603                 self.password = self.input_
604                 self.input_ = ""
605                 self.switch_mode('play')
606             elif self.mode == self.mode_chat and key == '\n':
607                 if self.input_[0] == '/':
608                     if self.input_ in {'/' + self.keys['switch_to_play'], '/play'}:
609                         self.switch_mode('play')
610                     elif self.input_ in {'/' + self.keys['switch_to_study'], '/study'}:
611                         self.switch_mode('study')
612                     elif self.input_ == '/reconnect':
613                         reconnect()
614                     elif self.input_.startswith('/nick'):
615                         tokens = self.input_.split(maxsplit=1)
616                         if len(tokens) == 2:
617                             self.send('NICK ' + quote(tokens[1]))
618                         else:
619                             self.log_msg('? need login name')
620                     elif self.input_.startswith('/msg'):
621                         tokens = self.input_.split(maxsplit=2)
622                         if len(tokens) == 3:
623                             self.send('QUERY %s %s' % (quote(tokens[1]),
624                                                               quote(tokens[2])))
625                         else:
626                             self.log_msg('? need message target and message')
627                     else:
628                         self.log_msg('? unknown command')
629                 else:
630                     self.send('ALL ' + quote(self.input_))
631                 self.input_ = ""
632             elif self.mode == self.mode_annotate and key == '\n':
633                 if self.input_ == '':
634                     self.input_ = ' '
635                 self.send('ANNOTATE %s %s' % (self.explorer, quote(self.input_)))
636                 self.input_ = ""
637                 self.switch_mode('study', keep_position=True)
638             elif self.mode == self.mode_portal and key == '\n':
639                 if self.input_ == '':
640                     self.input_ = ' '
641                 self.send('PORTAL %s %s' % (self.explorer, quote(self.input_)))
642                 self.input_ = ""
643                 self.switch_mode('study', keep_position=True)
644             elif self.mode == self.mode_teleport and key == '\n':
645                 if self.input_ == 'YES!':
646                     self.host = self.teleport_target_host
647                     reconnect()
648                 else:
649                     self.log_msg('@ teleport aborted')
650                     self.switch_mode('play')
651                 self.input_ = ''
652             elif self.mode == self.mode_study:
653                 if key == self.keys['switch_to_chat']:
654                     self.switch_mode('chat')
655                 elif key == self.keys['switch_to_play']:
656                     self.switch_mode('play')
657                 elif key == self.keys['switch_to_annotate']:
658                     self.switch_mode('annotate', keep_position=True)
659                 elif key == self.keys['switch_to_portal']:
660                     self.switch_mode('portal', keep_position=True)
661                 elif key == self.keys['toggle_map_mode']:
662                     if self.map_mode == 'terrain':
663                         self.map_mode = 'control'
664                     else:
665                         self.map_mode = 'terrain'
666                 elif key in self.movement_keys:
667                     move_explorer(self.movement_keys[key])
668             elif self.mode == self.mode_play:
669                 if key == self.keys['switch_to_chat']:
670                     self.switch_mode('chat')
671                 elif key == self.keys['switch_to_study']:
672                     self.switch_mode('study')
673                 elif key == self.keys['switch_to_password']:
674                     self.switch_mode('password')
675                 if key == self.keys['switch_to_edit'] and\
676                    'WRITE' in self.game.tasks:
677                     self.switch_mode('edit')
678                 elif key == self.keys['flatten'] and\
679                      'FLATTEN_SURROUNDINGS' in self.game.tasks:
680                     self.send('TASK:FLATTEN_SURROUNDINGS ' + quote(self.password))
681                 elif key in self.movement_keys and 'MOVE' in self.game.tasks:
682                     self.send('TASK:MOVE ' + self.movement_keys[key])
683             elif self.mode == self.mode_edit:
684                 self.send('TASK:WRITE %s %s' % (key, quote(self.password)))
685                 self.switch_mode('play')
686
687 TUI('localhost:5000')