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