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