home · contact · privacy
Improve documentation/help choise of words.
[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 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 are shown together with their "protection characters".  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 tiles control 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 control',
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 control',
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.host = host
383         self.game = Game()
384         self.game.tui = self
385         self.parser = Parser(self.game)
386         self.log = []
387         self.do_refresh = True
388         self.queue = queue.Queue()
389         self.login_name = None
390         self.map_mode = 'terrain + things'
391         self.password = 'foo'
392         self.switch_mode('waiting_for_server')
393         self.keys = {
394             'switch_to_chat': 't',
395             'switch_to_play': 'p',
396             'switch_to_password': 'P',
397             'switch_to_annotate': 'M',
398             'switch_to_portal': 'T',
399             'switch_to_study': '?',
400             'switch_to_edit': 'E',
401             'switch_to_write': 'm',
402             'switch_to_admin_enter': 'A',
403             'switch_to_control_pw_type': 'C',
404             'switch_to_control_tile_type': 'Q',
405             'flatten': 'F',
406             'take_thing': 'z',
407             'drop_thing': 'u',
408             'teleport': 'p',
409             'help': 'h',
410             'toggle_map_mode': 'M',
411             'toggle_tile_draw': 'm',
412             'hex_move_upleft': 'w',
413             'hex_move_upright': 'e',
414             'hex_move_right': 'd',
415             'hex_move_downright': 'x',
416             'hex_move_downleft': 'y',
417             'hex_move_left': 'a',
418             'square_move_up': 'w',
419             'square_move_left': 'a',
420             'square_move_down': 's',
421             'square_move_right': 'd',
422         }
423         if os.path.isfile('config.json'):
424             with open('config.json', 'r') as f:
425                 keys_conf = json.loads(f.read())
426             for k in keys_conf:
427                 self.keys[k] = keys_conf[k]
428         self.show_help = False
429         self.disconnected = True
430         self.force_instant_connect = True
431         self.input_lines = []
432         self.fov = ''
433         self.flash = False
434         curses.wrapper(self.loop)
435
436     def connect(self):
437
438         def handle_recv(msg):
439             if msg == 'BYE':
440                 self.socket.close()
441             else:
442                 self.queue.put(msg)
443
444         self.log_msg('@ attempting connect')
445         socket_client_class = PlomSocketClient
446         if self.host.startswith('ws://') or self.host.startswith('wss://'):
447             socket_client_class = WebSocketClient
448         try:
449             self.socket = socket_client_class(handle_recv, self.host)
450             self.socket_thread = threading.Thread(target=self.socket.run)
451             self.socket_thread.start()
452             self.disconnected = False
453             self.game.thing_types = {}
454             self.game.terrains = {}
455             self.socket.send('TASKS')
456             self.socket.send('TERRAINS')
457             self.socket.send('THING_TYPES')
458             self.switch_mode('login')
459         except ConnectionRefusedError:
460             self.log_msg('@ server connect failure')
461             self.disconnected = True
462             self.switch_mode('waiting_for_server')
463         self.do_refresh = True
464
465     def reconnect(self):
466         self.log_msg('@ attempting reconnect')
467         self.send('QUIT')
468         time.sleep(0.1)  # FIXME necessitated by some some strange SSL race
469                          # conditions with ws4py, find out what exactly
470         self.switch_mode('waiting_for_server')
471         self.connect()
472
473     def send(self, msg):
474         try:
475             if hasattr(self.socket, 'plom_closed') and self.socket.plom_closed:
476                 raise BrokenSocketConnection
477             self.socket.send(msg)
478         except (BrokenPipeError, BrokenSocketConnection):
479             self.log_msg('@ server disconnected :(')
480             self.disconnected = True
481             self.force_instant_connect = True
482             self.do_refresh = True
483
484     def log_msg(self, msg):
485         self.log += [msg]
486         if len(self.log) > 100:
487             self.log = self.log[-100:]
488
489     def query_info(self):
490         self.send('GET_ANNOTATION ' + str(self.explorer))
491
492     def restore_input_values(self):
493         if self.mode.name == 'annotate' and self.explorer in self.game.info_db:
494             info = self.game.info_db[self.explorer]
495             if info != '(none)':
496                 self.input_ = info
497         elif self.mode.name == 'portal' and self.explorer in self.game.portals:
498             self.input_ = self.game.portals[self.explorer]
499         elif self.mode.name == 'password':
500             self.input_ = self.password
501
502     def send_tile_control_command(self):
503         self.send('SET_TILE_CONTROL %s %s' %
504                   (self.explorer, quote(self.tile_control_char)))
505
506     def switch_mode(self, mode_name):
507         self.map_mode = 'terrain + things'
508         self.tile_draw = False
509         if mode_name == 'admin_enter' and self.is_admin:
510             mode_name = 'admin'
511         self.mode = getattr(self, 'mode_' + mode_name)
512         if self.mode.shows_info or self.mode.name == 'control_tile_draw':
513             player = self.game.get_thing(self.game.player_id)
514             self.explorer = YX(player.position.y, player.position.x)
515             if self.mode.shows_info:
516                 self.query_info()
517         if self.mode.is_single_char_entry:
518             self.show_help = True
519         if self.mode.name == 'waiting_for_server':
520             self.log_msg('@ waiting for server …')
521         elif self.mode.name == 'login':
522             if self.login_name:
523                 self.send('LOGIN ' + quote(self.login_name))
524             else:
525                 self.log_msg('@ enter username')
526         elif self.mode.name == 'admin_enter':
527             self.log_msg('@ enter admin password:')
528         elif self.mode.name == 'control_pw_type':
529             self.log_msg('@ enter tile control character for which you want to change the password:')
530         elif self.mode.name == 'control_tile_type':
531             self.log_msg('@ enter tile control character which you want to draw:')
532         elif self.mode.name == 'control_pw_pw':
533             self.log_msg('@ enter tile control password for "%s":' % self.tile_control_char)
534         elif self.mode.name == 'control_tile_draw':
535             self.log_msg('@ can draw tile control 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']))
536         self.input_ = ""
537         self.restore_input_values()
538
539     def loop(self, stdscr):
540         import datetime
541
542         def safe_addstr(y, x, line):
543             if y < self.size.y - 1 or x + len(line) < self.size.x:
544                 stdscr.addstr(y, x, line)
545             else:  # workaround to <https://stackoverflow.com/q/7063128>
546                 cut_i = self.size.x - x - 1
547                 cut = line[:cut_i]
548                 last_char = line[cut_i]
549                 stdscr.addstr(y, self.size.x - 2, last_char)
550                 stdscr.insstr(y, self.size.x - 2, ' ')
551                 stdscr.addstr(y, x, cut)
552
553         def handle_input(msg):
554             command, args = self.parser.parse(msg)
555             command(*args)
556
557         def msg_into_lines_of_width(msg, width):
558             chunk = ''
559             lines = []
560             x = 0
561             for i in range(len(msg)):
562                 if x >= width or msg[i] == "\n":
563                     lines += [chunk]
564                     chunk = ''
565                     x = 0
566                     if msg[i] == "\n":
567                         x -= 1
568                 if msg[i] != "\n":
569                     chunk += msg[i]
570                 x += 1
571             lines += [chunk]
572             return lines
573
574         def reset_screen_size():
575             self.size = YX(*stdscr.getmaxyx())
576             self.size = self.size - YX(self.size.y % 4, 0)
577             self.size = self.size - YX(0, self.size.x % 4)
578             self.window_width = int(self.size.x / 2)
579
580         def recalc_input_lines():
581             if not self.mode.has_input_prompt:
582                 self.input_lines = []
583             else:
584                 self.input_lines = msg_into_lines_of_width(input_prompt + self.input_,
585                                                            self.window_width)
586
587         def move_explorer(direction):
588             target = self.game.map_geometry.move_yx(self.explorer, direction)
589             if target:
590                 self.explorer = target
591                 if self.mode.shows_info:
592                     self.query_info()
593                 if self.tile_draw:
594                     self.send_tile_control_command()
595             else:
596                 self.flash = True
597
598         def draw_history():
599             lines = []
600             for line in self.log:
601                 lines += msg_into_lines_of_width(line, self.window_width)
602             lines.reverse()
603             height_header = 2
604             max_y = self.size.y - len(self.input_lines)
605             for i in range(len(lines)):
606                 if (i >= max_y - height_header):
607                     break
608                 safe_addstr(max_y - i - 1, self.window_width, lines[i])
609
610         def draw_info():
611             if not self.game.turn_complete:
612                 return
613             pos_i = self.explorer.y * self.game.map_geometry.size.x + self.explorer.x
614             info = 'MAP VIEW: %s\n' % self.map_mode
615             if self.game.fov[pos_i] != '.':
616                 info += 'outside field of view'
617             else:
618                 terrain_char = self.game.map_content[pos_i]
619                 terrain_desc = '?'
620                 if terrain_char in self.game.terrains:
621                     terrain_desc = self.game.terrains[terrain_char]
622                 info += 'TERRAIN: "%s" / %s\n' % (terrain_char, terrain_desc)
623                 protection = self.game.map_control_content[pos_i]
624                 if protection == '.':
625                     protection = 'unprotected'
626                 info += 'PROTECTION: %s\n' % protection
627                 for t in self.game.things:
628                     if t.position == self.explorer:
629                         info += 'THING: %s / %s' % (t.type_,
630                                                     self.game.thing_types[t.type_])
631                         if hasattr(t, 'player_char'):
632                             info += t.player_char
633                         if hasattr(t, 'name'):
634                             info += ' (%s)' % t.name
635                         info += '\n'
636                 if self.explorer in self.game.portals:
637                     info += 'PORTAL: ' + self.game.portals[self.explorer] + '\n'
638                 else:
639                     info += 'PORTAL: (none)\n'
640                 if self.explorer in self.game.info_db:
641                     info += 'ANNOTATION: ' + self.game.info_db[self.explorer]
642                 else:
643                     info += 'ANNOTATION: waiting …'
644             lines = msg_into_lines_of_width(info, self.window_width)
645             height_header = 2
646             for i in range(len(lines)):
647                 y = height_header + i
648                 if y >= self.size.y - len(self.input_lines):
649                     break
650                 safe_addstr(y, self.window_width, lines[i])
651
652         def draw_input():
653             y = self.size.y - len(self.input_lines)
654             for i in range(len(self.input_lines)):
655                 safe_addstr(y, self.window_width, self.input_lines[i])
656                 y += 1
657
658         def draw_turn():
659             if not self.game.turn_complete:
660                 return
661             safe_addstr(0, self.window_width, 'TURN: ' + str(self.game.turn))
662
663         def draw_mode():
664             help = "hit [%s] for help" % self.keys['help']
665             if self.mode.has_input_prompt:
666                 help = "enter /help for help"
667             safe_addstr(1, self.window_width,
668                         'MODE: %s – %s' % (self.mode.short_desc, help))
669
670         def draw_map():
671             if not self.game.turn_complete:
672                 return
673             map_lines_split = []
674             for y in range(self.game.map_geometry.size.y):
675                 start = self.game.map_geometry.size.x * y
676                 end = start + self.game.map_geometry.size.x
677                 if self.mode.name in {'edit', 'write', 'control_tile_draw',
678                                       'control_tile_type'}:
679                     line = []
680                     for i in range(start, end):
681                         line += [self.game.map_content[i]
682                                  + self.game.map_control_content[i]]
683                     map_lines_split += [line]
684                 else:
685                     map_lines_split += [[c + ' ' for c
686                                          in self.game.map_content[start:end]]]
687             if self.map_mode == 'terrain + annotations':
688                 for p in self.game.info_hints:
689                     map_lines_split[p.y][p.x] = 'A '
690             elif self.map_mode == 'terrain + things':
691                 for p in self.game.portals.keys():
692                     original = map_lines_split[p.y][p.x]
693                     map_lines_split[p.y][p.x] = original[0] + 'P'
694                 used_positions = []
695                 for t in self.game.things:
696                     symbol = self.game.thing_types[t.type_]
697                     meta_char = ' '
698                     if hasattr(t, 'player_char'):
699                         meta_char = t.player_char
700                     if t.position in used_positions:
701                         meta_char = '+'
702                     map_lines_split[t.position.y][t.position.x] = symbol + meta_char
703                     used_positions += [t.position]
704             if self.mode.shows_info or self.mode.name == 'control_tile_draw':
705                 map_lines_split[self.explorer.y][self.explorer.x] = '??'
706             map_lines = []
707             if type(self.game.map_geometry) == MapGeometryHex:
708                 indent = 0
709                 for line in map_lines_split:
710                     map_lines += [indent*' ' + ''.join(line)]
711                     indent = 0 if indent else 1
712             else:
713                 for line in map_lines_split:
714                     map_lines += [''.join(line)]
715             window_center = YX(int(self.size.y / 2),
716                                int(self.window_width / 2))
717             player = self.game.get_thing(self.game.player_id)
718             center = player.position
719             if self.mode.shows_info:
720                 center = self.explorer
721             center = YX(center.y, center.x * 2)
722             offset = center - window_center
723             if type(self.game.map_geometry) == MapGeometryHex and offset.y % 2:
724                 offset += YX(0, 1)
725             term_y = max(0, -offset.y)
726             term_x = max(0, -offset.x)
727             map_y = max(0, offset.y)
728             map_x = max(0, offset.x)
729             while (term_y < self.size.y and map_y < self.game.map_geometry.size.y):
730                 to_draw = map_lines[map_y][map_x:self.window_width + offset.x]
731                 safe_addstr(term_y, term_x, to_draw)
732                 term_y += 1
733                 map_y += 1
734
735         def draw_help():
736             content = "%s help\n\n%s\n\n" % (self.mode.short_desc,
737                                              self.mode.help_intro)
738             if self.mode.name == 'play':
739                 content += "Available actions:\n"
740                 if 'MOVE' in self.game.tasks:
741                     content += "[%s] – move player\n" % ','.join(self.movement_keys)
742                 if 'PICK_UP' in self.game.tasks:
743                     content += "[%s] – pick up thing\n" % self.keys['take_thing']
744                 if 'DROP' in self.game.tasks:
745                     content += "[%s] – drop thing\n" % self.keys['drop_thing']
746                 content += '[%s] – teleport\n' % self.keys['teleport']
747                 content += '\n'
748             elif self.mode.name == 'study':
749                 content += 'Available actions:\n'
750                 content += '[%s] – move question mark\n' % ','.join(self.movement_keys)
751                 content += '[%s] – toggle map view\n' % self.keys['toggle_map_mode']
752                 content += '\n'
753             elif self.mode.name == 'edit':
754                 content += "Available actions:\n"
755                 if 'FLATTEN_SURROUNDINGS' in self.game.tasks:
756                     content += "[%s] – flatten surroundings\n" % self.keys['flatten']
757                 content += '\n'
758             elif self.mode.name == 'control_tile_draw':
759                 content += "Available actions:\n"
760                 content += "[%s] – toggle tile control drawing\n" % self.keys['toggle_tile_draw']
761                 content += '\n'
762             elif self.mode.name == 'chat':
763                 content += '/nick NAME – re-name yourself to NAME\n'
764                 content += '/%s or /play – switch to play mode\n' % self.keys['switch_to_play']
765                 content += '/%s or /study – switch to study mode\n' % self.keys['switch_to_study']
766                 content += '/%s or /edit – switch to map edit mode\n' % self.keys['switch_to_edit']
767                 content += '/%s or /admin – switch to admin mode\n' % self.keys['switch_to_admin_enter']
768             content += self.mode.list_available_modes(self)
769             for i in range(self.size.y):
770                 safe_addstr(i,
771                             self.window_width * (not self.mode.has_input_prompt),
772                             ' '*self.window_width)
773             lines = []
774             for line in content.split('\n'):
775                 lines += msg_into_lines_of_width(line, self.window_width)
776             for i in range(len(lines)):
777                 if i >= self.size.y:
778                     break
779                 safe_addstr(i,
780                             self.window_width * (not self.mode.has_input_prompt),
781                             lines[i])
782
783         def draw_screen():
784             stdscr.clear()
785             if self.mode.has_input_prompt:
786                 recalc_input_lines()
787                 draw_input()
788             if self.mode.shows_info:
789                 draw_info()
790             else:
791                 draw_history()
792             draw_mode()
793             if not self.mode.is_intro:
794                 draw_turn()
795                 draw_map()
796             if self.show_help:
797                 draw_help()
798
799         curses.curs_set(False)  # hide cursor
800         curses.use_default_colors();
801         stdscr.timeout(10)
802         reset_screen_size()
803         self.explorer = YX(0, 0)
804         self.input_ = ''
805         input_prompt = '> '
806         interval = datetime.timedelta(seconds=5)
807         last_ping = datetime.datetime.now() - interval
808         while True:
809             if self.disconnected and self.force_instant_connect:
810                 self.force_instant_connect = False
811                 self.connect()
812             now = datetime.datetime.now()
813             if now - last_ping > interval:
814                 if self.disconnected:
815                     self.connect()
816                 else:
817                     self.send('PING')
818                 last_ping = now
819             if self.flash:
820                 curses.flash()
821                 self.flash = False
822             if self.do_refresh:
823                 draw_screen()
824                 self.do_refresh = False
825             while True:
826                 try:
827                     msg = self.queue.get(block=False)
828                     handle_input(msg)
829                 except queue.Empty:
830                     break
831             try:
832                 key = stdscr.getkey()
833                 self.do_refresh = True
834             except curses.error:
835                 continue
836             self.show_help = False
837             if key == 'KEY_RESIZE':
838                 reset_screen_size()
839             elif self.mode.has_input_prompt and key == 'KEY_BACKSPACE':
840                 self.input_ = self.input_[:-1]
841             elif self.mode.has_input_prompt and key == '\n' and self.input_ == '/help':
842                 self.show_help = True
843                 self.input_ = ""
844                 self.restore_input_values()
845             elif self.mode.has_input_prompt and key != '\n':  # Return key
846                 self.input_ += key
847                 max_length = self.window_width * self.size.y - len(input_prompt) - 1
848                 if len(self.input_) > max_length:
849                     self.input_ = self.input_[:max_length]
850             elif key == self.keys['help'] and not self.mode.is_single_char_entry:
851                 self.show_help = True
852             elif self.mode.name == 'login' and key == '\n':
853                 self.login_name = self.input_
854                 self.send('LOGIN ' + quote(self.input_))
855                 self.input_ = ""
856             elif self.mode.name == 'control_pw_pw' and key == '\n':
857                 if self.input_ == '':
858                     self.log_msg('@ aborted')
859                 else:
860                     self.send('SET_MAP_CONTROL_PASSWORD ' + quote(self.tile_control_char) + ' ' + quote(self.input_))
861                 self.switch_mode('admin')
862             elif self.mode.name == 'password' and key == '\n':
863                 if self.input_ == '':
864                     self.input_ = ' '
865                 self.password = self.input_
866                 self.switch_mode('edit')
867             elif self.mode.name == 'admin_enter' and key == '\n':
868                 self.send('BECOME_ADMIN ' + quote(self.input_))
869                 self.switch_mode('play')
870             elif self.mode.name == 'control_pw_type' and key == '\n':
871                 if len(self.input_) != 1:
872                     self.log_msg('@ entered non-single-char, therefore aborted')
873                     self.switch_mode('admin')
874                 else:
875                     self.tile_control_char = self.input_
876                     self.switch_mode('control_pw_pw')
877             elif self.mode.name == 'control_tile_type' and key == '\n':
878                 if len(self.input_) != 1:
879                     self.log_msg('@ entered non-single-char, therefore aborted')
880                     self.switch_mode('admin')
881                 else:
882                     self.tile_control_char = self.input_
883                     self.switch_mode('control_tile_draw')
884             elif self.mode.name == 'chat' and key == '\n':
885                 if self.input_ == '':
886                     continue
887                 if self.input_[0] == '/':  # FIXME fails on empty input
888                     if self.input_ in {'/' + self.keys['switch_to_play'], '/play'}:
889                         self.switch_mode('play')
890                     elif self.input_ in {'/' + self.keys['switch_to_study'], '/study'}:
891                         self.switch_mode('study')
892                     elif self.input_ in {'/' + self.keys['switch_to_edit'], '/edit'}:
893                         self.switch_mode('edit')
894                     elif self.input_ in {'/' + self.keys['switch_to_admin_enter'], '/admin'}:
895                         self.switch_mode('admin_enter')
896                     elif self.input_.startswith('/nick'):
897                         tokens = self.input_.split(maxsplit=1)
898                         if len(tokens) == 2:
899                             self.send('NICK ' + quote(tokens[1]))
900                         else:
901                             self.log_msg('? need login name')
902                     else:
903                         self.log_msg('? unknown command')
904                 else:
905                     self.send('ALL ' + quote(self.input_))
906                 self.input_ = ""
907             elif self.mode.name == 'annotate' and key == '\n':
908                 if self.input_ == '':
909                     self.input_ = ' '
910                 self.send('ANNOTATE %s %s %s' % (self.explorer, quote(self.input_),
911                                                  quote(self.password)))
912                 self.switch_mode('edit')
913             elif self.mode.name == 'portal' and key == '\n':
914                 if self.input_ == '':
915                     self.input_ = ' '
916                 self.send('PORTAL %s %s %s' % (self.explorer, quote(self.input_),
917                                                quote(self.password)))
918                 self.switch_mode('edit')
919             elif self.mode.name == 'study':
920                 if self.mode.mode_switch_on_key(self, key):
921                     continue
922                 elif key == self.keys['toggle_map_mode']:
923                     if self.map_mode == 'terrain only':
924                         self.map_mode = 'terrain + annotations'
925                     elif self.map_mode == 'terrain + annotations':
926                         self.map_mode = 'terrain + things'
927                     else:
928                         self.map_mode = 'terrain only'
929                 elif key in self.movement_keys:
930                     move_explorer(self.movement_keys[key])
931             elif self.mode.name == 'play':
932                 if self.mode.mode_switch_on_key(self, key):
933                     continue
934                 elif key == self.keys['take_thing'] and 'PICK_UP' in self.game.tasks:
935                     self.send('TASK:PICK_UP')
936                 elif key == self.keys['drop_thing'] and 'DROP' in self.game.tasks:
937                     self.send('TASK:DROP')
938                 elif key == self.keys['teleport']:
939                     player = self.game.get_thing(self.game.player_id)
940                     if player.position in self.game.portals:
941                         self.host = self.game.portals[player.position]
942                         self.reconnect()
943                     else:
944                         self.flash = True
945                         self.log_msg('? not standing on portal')
946                 elif key in self.movement_keys and 'MOVE' in self.game.tasks:
947                     self.send('TASK:MOVE ' + self.movement_keys[key])
948             elif self.mode.name == 'write':
949                 self.send('TASK:WRITE %s %s' % (key, quote(self.password)))
950                 self.switch_mode('edit')
951             elif self.mode.name == 'control_tile_draw':
952                 if self.mode.mode_switch_on_key(self, key):
953                     continue
954                 elif key in self.movement_keys:
955                     move_explorer(self.movement_keys[key])
956                 elif key == self.keys['toggle_tile_draw']:
957                     self.tile_draw = False if self.tile_draw else True
958             elif self.mode.name == 'admin':
959                 if self.mode.mode_switch_on_key(self, key):
960                     continue
961             elif self.mode.name == 'edit':
962                 if self.mode.mode_switch_on_key(self, key):
963                     continue
964                 if key == self.keys['flatten'] and\
965                      'FLATTEN_SURROUNDINGS' in self.game.tasks:
966                     self.send('TASK:FLATTEN_SURROUNDINGS ' + quote(self.password))
967                 elif key in self.movement_keys and 'MOVE' in self.game.tasks:
968                     self.send('TASK:MOVE ' + self.movement_keys[key])
969
970 if len(sys.argv) != 2:
971     raise ArgError('wrong number of arguments, need game host')
972 host = sys.argv[1]
973 TUI(host)