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