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