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