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