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