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