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