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