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