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