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