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