home · contact · privacy
Exclude non-portable things from pick_up selection.
[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_CARRYING(game, thing_id):
296     game.get_thing(thing_id).carrying = True
297 cmd_THING_CARRYING.argtypes = 'int:nonneg'
298
299 def cmd_TERRAIN(game, terrain_char, terrain_desc):
300     game.terrains[terrain_char] = terrain_desc
301 cmd_TERRAIN.argtypes = 'char string'
302
303 def cmd_PONG(game):
304     pass
305 cmd_PONG.argtypes = ''
306
307 def cmd_DEFAULT_COLORS(game):
308     game.tui.set_default_colors()
309 cmd_DEFAULT_COLORS.argtypes = ''
310
311 def cmd_RANDOM_COLORS(game):
312     game.tui.set_random_colors()
313 cmd_RANDOM_COLORS.argtypes = ''
314
315 class Game(GameBase):
316     turn_complete = False
317     tasks = {}
318     thing_types = {}
319
320     def __init__(self, *args, **kwargs):
321         super().__init__(*args, **kwargs)
322         self.register_command(cmd_LOGIN_OK)
323         self.register_command(cmd_ADMIN_OK)
324         self.register_command(cmd_PONG)
325         self.register_command(cmd_CHAT)
326         self.register_command(cmd_REPLY)
327         self.register_command(cmd_PLAYER_ID)
328         self.register_command(cmd_TURN)
329         self.register_command(cmd_THING)
330         self.register_command(cmd_THING_TYPE)
331         self.register_command(cmd_THING_NAME)
332         self.register_command(cmd_THING_CHAR)
333         self.register_command(cmd_THING_CARRYING)
334         self.register_command(cmd_TERRAIN)
335         self.register_command(cmd_MAP)
336         self.register_command(cmd_MAP_CONTROL)
337         self.register_command(cmd_PORTAL)
338         self.register_command(cmd_ANNOTATION)
339         self.register_command(cmd_GAME_STATE_COMPLETE)
340         self.register_command(cmd_ARGUMENT_ERROR)
341         self.register_command(cmd_GAME_ERROR)
342         self.register_command(cmd_PLAY_ERROR)
343         self.register_command(cmd_TASKS)
344         self.register_command(cmd_FOV)
345         self.register_command(cmd_DEFAULT_COLORS)
346         self.register_command(cmd_RANDOM_COLORS)
347         self.map_content = ''
348         self.player_id = -1
349         self.annotations = {}
350         self.portals = {}
351         self.terrains = {}
352
353     def get_string_options(self, string_option_type):
354         if string_option_type == 'map_geometry':
355             return ['Hex', 'Square']
356         elif string_option_type == 'thing_type':
357             return self.thing_types.keys()
358         return None
359
360     def get_command(self, command_name):
361         from functools import partial
362         f = partial(self.commands[command_name], self)
363         f.argtypes = self.commands[command_name].argtypes
364         return f
365
366 class Mode:
367
368     def __init__(self, name, has_input_prompt=False, shows_info=False,
369                  is_intro=False, is_single_char_entry=False):
370         self.name = name
371         self.short_desc = mode_helps[name]['short']
372         self.available_modes = []
373         self.available_actions = []
374         self.has_input_prompt = has_input_prompt
375         self.shows_info = shows_info
376         self.is_intro = is_intro
377         self.help_intro = mode_helps[name]['long']
378         self.intro_msg = mode_helps[name]['intro']
379         self.is_single_char_entry = is_single_char_entry
380         self.legal = True
381
382     def iter_available_modes(self, tui):
383         for mode_name in self.available_modes:
384             mode = getattr(tui, 'mode_' + mode_name)
385             if not mode.legal:
386                 continue
387             key = tui.keys['switch_to_' + mode.name]
388             yield mode, key
389
390     def list_available_modes(self, tui):
391         msg = ''
392         if len(self.available_modes) > 0:
393             msg = 'Other modes available from here:\n'
394             for mode, key in self.iter_available_modes(tui):
395                 msg += '[%s] – %s\n' % (key, mode.short_desc)
396         return msg
397
398     def mode_switch_on_key(self, tui, key_pressed):
399         for mode, key in self.iter_available_modes(tui):
400             if key_pressed == key:
401                 tui.switch_mode(mode.name)
402                 return True
403         return False
404
405 class TUI:
406     mode_admin_enter = Mode('admin_enter', has_input_prompt=True)
407     mode_admin = Mode('admin')
408     mode_play = Mode('play')
409     mode_study = Mode('study', shows_info=True)
410     mode_write = Mode('write', is_single_char_entry=True)
411     mode_edit = Mode('edit')
412     mode_control_pw_type = Mode('control_pw_type', has_input_prompt=True)
413     mode_control_pw_pw = Mode('control_pw_pw', has_input_prompt=True)
414     mode_control_tile_type = Mode('control_tile_type', has_input_prompt=True)
415     mode_control_tile_draw = Mode('control_tile_draw')
416     mode_admin_thing_protect = Mode('admin_thing_protect', has_input_prompt=True)
417     mode_annotate = Mode('annotate', has_input_prompt=True, shows_info=True)
418     mode_portal = Mode('portal', has_input_prompt=True, shows_info=True)
419     mode_chat = Mode('chat', has_input_prompt=True)
420     mode_waiting_for_server = Mode('waiting_for_server', is_intro=True)
421     mode_login = Mode('login', has_input_prompt=True, is_intro=True)
422     mode_post_login_wait = Mode('post_login_wait', is_intro=True)
423     mode_password = Mode('password', has_input_prompt=True)
424     mode_name_thing = Mode('name_thing', has_input_prompt=True, shows_info=True)
425     mode_command_thing = Mode('command_thing', has_input_prompt=True)
426     mode_take_thing = Mode('take_thing', has_input_prompt=True)
427     is_admin = False
428     tile_draw = False
429
430     def __init__(self, host):
431         import os
432         import json
433         self.mode_play.available_modes = ["chat", "study", "edit", "admin_enter",
434                                           "command_thing", "take_thing"]
435         self.mode_play.available_actions = ["move", "drop_thing",
436                                             "teleport", "door", "consume",
437                                             "install"]
438         self.mode_study.available_modes = ["chat", "play", "admin_enter", "edit"]
439         self.mode_study.available_actions = ["toggle_map_mode", "move_explorer"]
440         self.mode_admin.available_modes = ["admin_thing_protect", "control_pw_type",
441                                            "control_tile_type", "chat",
442                                            "study", "play", "edit"]
443         self.mode_admin.available_actions = ["move"]
444         self.mode_control_tile_draw.available_modes = ["admin_enter"]
445         self.mode_control_tile_draw.available_actions = ["move_explorer",
446                                                          "toggle_tile_draw"]
447         self.mode_edit.available_modes = ["write", "annotate", "portal", "name_thing",
448                                           "password", "chat", "study", "play",
449                                           "admin_enter"]
450         self.mode_edit.available_actions = ["move", "flatten", "toggle_map_mode"]
451         self.mode = None
452         self.host = host
453         self.game = Game()
454         self.game.tui = self
455         self.parser = Parser(self.game)
456         self.log = []
457         self.do_refresh = True
458         self.queue = queue.Queue()
459         self.login_name = None
460         self.map_mode = 'terrain + things'
461         self.password = 'foo'
462         self.switch_mode('waiting_for_server')
463         self.keys = {
464             'switch_to_chat': 't',
465             'switch_to_play': 'p',
466             'switch_to_password': 'P',
467             'switch_to_annotate': 'M',
468             'switch_to_portal': 'T',
469             'switch_to_study': '?',
470             'switch_to_edit': 'E',
471             'switch_to_write': 'm',
472             'switch_to_name_thing': 'N',
473             'switch_to_command_thing': 'O',
474             'switch_to_admin_enter': 'A',
475             'switch_to_control_pw_type': 'C',
476             'switch_to_control_tile_type': 'Q',
477             'switch_to_admin_thing_protect': 'T',
478             'flatten': 'F',
479             'switch_to_take_thing': 'z',
480             'drop_thing': 'u',
481             'teleport': 'p',
482             'consume': 'C',
483             'door': 'D',
484             'install': 'I',
485             'help': 'h',
486             'toggle_map_mode': 'L',
487             'toggle_tile_draw': 'm',
488             'hex_move_upleft': 'w',
489             'hex_move_upright': 'e',
490             'hex_move_right': 'd',
491             'hex_move_downright': 'x',
492             'hex_move_downleft': 'y',
493             'hex_move_left': 'a',
494             'square_move_up': 'w',
495             'square_move_left': 'a',
496             'square_move_down': 's',
497             'square_move_right': 'd',
498         }
499         if os.path.isfile('config.json'):
500             with open('config.json', 'r') as f:
501                 keys_conf = json.loads(f.read())
502             for k in keys_conf:
503                 self.keys[k] = keys_conf[k]
504         self.show_help = False
505         self.disconnected = True
506         self.force_instant_connect = True
507         self.input_lines = []
508         self.fov = ''
509         self.flash = False
510         self.map_lines = []
511         self.offset = YX(0,0)
512         curses.wrapper(self.loop)
513
514     def connect(self):
515
516         def handle_recv(msg):
517             if msg == 'BYE':
518                 self.socket.close()
519             else:
520                 self.queue.put(msg)
521
522         self.log_msg('@ attempting connect')
523         socket_client_class = PlomSocketClient
524         if self.host.startswith('ws://') or self.host.startswith('wss://'):
525             socket_client_class = WebSocketClient
526         try:
527             self.socket = socket_client_class(handle_recv, self.host)
528             self.socket_thread = threading.Thread(target=self.socket.run)
529             self.socket_thread.start()
530             self.disconnected = False
531             self.game.thing_types = {}
532             self.game.terrains = {}
533             time.sleep(0.1)  # give potential SSL negotation some time …
534             self.socket.send('TASKS')
535             self.socket.send('TERRAINS')
536             self.socket.send('THING_TYPES')
537             self.switch_mode('login')
538         except ConnectionRefusedError:
539             self.log_msg('@ server connect failure')
540             self.disconnected = True
541             self.switch_mode('waiting_for_server')
542         self.do_refresh = True
543
544     def reconnect(self):
545         self.log_msg('@ attempting reconnect')
546         self.send('QUIT')
547         # necessitated by some strange SSL race conditions with ws4py
548         time.sleep(0.1)  # FIXME find out why exactly necessary
549         self.switch_mode('waiting_for_server')
550         self.connect()
551
552     def send(self, msg):
553         try:
554             if hasattr(self.socket, 'plom_closed') and self.socket.plom_closed:
555                 raise BrokenSocketConnection
556             self.socket.send(msg)
557         except (BrokenPipeError, BrokenSocketConnection):
558             self.log_msg('@ server disconnected :(')
559             self.disconnected = True
560             self.force_instant_connect = True
561             self.do_refresh = True
562
563     def log_msg(self, msg):
564         self.log += [msg]
565         if len(self.log) > 100:
566             self.log = self.log[-100:]
567
568     def restore_input_values(self):
569         if self.mode.name == 'annotate' and self.explorer in self.game.annotations:
570             self.input_ = self.game.annotations[self.explorer]
571         elif self.mode.name == 'portal' and self.explorer in self.game.portals:
572             self.input_ = self.game.portals[self.explorer]
573         elif self.mode.name == 'password':
574             self.input_ = self.password
575         elif self.mode.name == 'name_thing':
576             if hasattr(self.thing_selected, 'name'):
577                 self.input_ = self.thing_selected.name
578         elif self.mode.name == 'admin_thing_protect':
579             if hasattr(self.thing_selected, 'protection'):
580                 self.input_ = self.thing_selected.protection
581
582     def send_tile_control_command(self):
583         self.send('SET_TILE_CONTROL %s %s' %
584                   (self.explorer, quote(self.tile_control_char)))
585
586     def toggle_map_mode(self):
587         if self.map_mode == 'terrain only':
588             self.map_mode = 'terrain + annotations'
589         elif self.map_mode == 'terrain + annotations':
590             self.map_mode = 'terrain + things'
591         elif self.map_mode == 'terrain + things':
592             self.map_mode = 'protections'
593         elif self.map_mode == 'protections':
594             self.map_mode = 'terrain only'
595
596     def switch_mode(self, mode_name):
597         if self.mode and self.mode.name == 'control_tile_draw':
598             self.log_msg('@ finished tile protection drawing.')
599         self.tile_draw = False
600         if mode_name == 'admin_enter' and self.is_admin:
601             mode_name = 'admin'
602         elif mode_name in {'name_thing', 'admin_thing_protect'}:
603             player = self.game.get_thing(self.game.player_id)
604             thing = None
605             for t in [t for t in self.game.things if t.position == player.position
606                       and t.id_ != player.id_]:
607                 thing = t
608                 break
609             if not thing:
610                 self.flash = True
611                 self.log_msg('? not standing over thing')
612                 return
613             else:
614                 self.thing_selected = thing
615         self.mode = getattr(self, 'mode_' + mode_name)
616         if self.mode.name in {'control_tile_draw', 'control_tile_type',
617                               'control_pw_type'}:
618             self.map_mode = 'protections'
619         elif self.mode.name != 'edit':
620             self.map_mode = 'terrain + things'
621         if self.mode.shows_info or self.mode.name == 'control_tile_draw':
622             player = self.game.get_thing(self.game.player_id)
623             self.explorer = YX(player.position.y, player.position.x)
624         if self.mode.is_single_char_entry:
625             self.show_help = True
626         if len(self.mode.intro_msg) > 0:
627             self.log_msg(self.mode.intro_msg)
628         if self.mode.name == 'login':
629             if self.login_name:
630                 self.send('LOGIN ' + quote(self.login_name))
631             else:
632                 self.log_msg('@ enter username')
633         elif self.mode.name == 'take_thing':
634             self.log_msg('Portable things in reach for pick-up:')
635             player = self.game.get_thing(self.game.player_id)
636             select_range = [player.position,
637                             player.position + YX(0,-1),
638                             player.position + YX(0, 1),
639                             player.position + YX(-1, 0),
640                             player.position + YX(1, 0)]
641             if type(self.game.map_geometry) == MapGeometryHex:
642                 if player.position.y % 2:
643                     select_range += [player.position + YX(-1, 1),
644                                      player.position + YX(1, 1)]
645                 else:
646                     select_range += [player.position + YX(-1, -1),
647                                      player.position + YX(1, -1)]
648             self.selectables = [t for t in self.game.things
649                                 if t.portable 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)