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