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