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