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