home · contact · privacy
In curses client, eliminate lag flickering.
[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         self.map_lines = []
468         self.offset = YX(0,0)
469         curses.wrapper(self.loop)
470
471     def connect(self):
472
473         def handle_recv(msg):
474             if msg == 'BYE':
475                 self.socket.close()
476             else:
477                 self.queue.put(msg)
478
479         self.log_msg('@ attempting connect')
480         socket_client_class = PlomSocketClient
481         if self.host.startswith('ws://') or self.host.startswith('wss://'):
482             socket_client_class = WebSocketClient
483         try:
484             self.socket = socket_client_class(handle_recv, self.host)
485             self.socket_thread = threading.Thread(target=self.socket.run)
486             self.socket_thread.start()
487             self.disconnected = False
488             self.game.thing_types = {}
489             self.game.terrains = {}
490             time.sleep(0.1)  # give potential SSL negotation some time …
491             self.socket.send('TASKS')
492             self.socket.send('TERRAINS')
493             self.socket.send('THING_TYPES')
494             self.switch_mode('login')
495         except ConnectionRefusedError:
496             self.log_msg('@ server connect failure')
497             self.disconnected = True
498             self.switch_mode('waiting_for_server')
499         self.do_refresh = True
500
501     def reconnect(self):
502         self.log_msg('@ attempting reconnect')
503         self.send('QUIT')
504         # necessitated by some strange SSL race conditions with ws4py
505         time.sleep(0.1)  # FIXME find out why exactly necessary
506         self.switch_mode('waiting_for_server')
507         self.connect()
508
509     def send(self, msg):
510         try:
511             if hasattr(self.socket, 'plom_closed') and self.socket.plom_closed:
512                 raise BrokenSocketConnection
513             self.socket.send(msg)
514         except (BrokenPipeError, BrokenSocketConnection):
515             self.log_msg('@ server disconnected :(')
516             self.disconnected = True
517             self.force_instant_connect = True
518             self.do_refresh = True
519
520     def log_msg(self, msg):
521         self.log += [msg]
522         if len(self.log) > 100:
523             self.log = self.log[-100:]
524
525     def query_info(self):
526         self.send('GET_ANNOTATION ' + str(self.explorer))
527
528     def restore_input_values(self):
529         if self.mode.name == 'annotate' and self.explorer in self.game.info_db:
530             info = self.game.info_db[self.explorer]
531             if info != '(none)':
532                 self.input_ = info
533         elif self.mode.name == 'portal' and self.explorer in self.game.portals:
534             self.input_ = self.game.portals[self.explorer]
535         elif self.mode.name == 'password':
536             self.input_ = self.password
537         elif self.mode.name == 'name_thing':
538             if hasattr(self.thing_selected, 'name'):
539                 self.input_ = self.thing_selected.name
540         elif self.mode.name == 'admin_thing_protect':
541             if hasattr(self.thing_selected, 'protection'):
542                 self.input_ = self.thing_selected.protection
543
544     def send_tile_control_command(self):
545         self.send('SET_TILE_CONTROL %s %s' %
546                   (self.explorer, quote(self.tile_control_char)))
547
548     def toggle_map_mode(self):
549         if self.map_mode == 'terrain only':
550             self.map_mode = 'terrain + annotations'
551         elif self.map_mode == 'terrain + annotations':
552             self.map_mode = 'terrain + things'
553         elif self.map_mode == 'terrain + things':
554             self.map_mode = 'protections'
555         elif self.map_mode == 'protections':
556             self.map_mode = 'terrain only'
557
558     def switch_mode(self, mode_name):
559         self.tile_draw = False
560         if mode_name == 'admin_enter' and self.is_admin:
561             mode_name = 'admin'
562         elif mode_name in {'name_thing', 'admin_thing_protect'}:
563             player = self.game.get_thing(self.game.player_id)
564             thing = None
565             for t in [t for t in self.game.things if t.position == player.position
566                       and t.id_ != player.id_]:
567                 thing = t
568                 break
569             if not thing:
570                 self.flash = True
571                 self.log_msg('? not standing over thing')
572                 return
573             else:
574                 self.thing_selected = thing
575         self.mode = getattr(self, 'mode_' + mode_name)
576         if self.mode.name == 'control_tile_draw':
577             self.log_msg('@ finished tile protection drawing.')
578         if self.mode.name in {'control_tile_draw', 'control_tile_type',
579                               'control_pw_type'}:
580             self.map_mode = 'protections'
581         elif self.mode.name != 'edit':
582             self.map_mode = 'terrain + things'
583         if self.mode.shows_info or self.mode.name == 'control_tile_draw':
584             player = self.game.get_thing(self.game.player_id)
585             self.explorer = YX(player.position.y, player.position.x)
586             if self.mode.shows_info:
587                 self.query_info()
588         if self.mode.is_single_char_entry:
589             self.show_help = True
590         if self.mode.name == 'waiting_for_server':
591             self.log_msg('@ waiting for server …')
592         elif self.mode.name == 'login':
593             if self.login_name:
594                 self.send('LOGIN ' + quote(self.login_name))
595             else:
596                 self.log_msg('@ enter username')
597         elif self.mode.name == 'admin_enter':
598             self.log_msg('@ enter admin password:')
599         elif self.mode.name == 'control_pw_type':
600             self.log_msg('@ enter protection character for which you want to change the password:')
601         elif self.mode.name == 'control_tile_type':
602             self.log_msg('@ enter protection character which you want to draw:')
603         elif self.mode.name == 'admin_thing_protect':
604             self.log_msg('@ enter thing protection character:')
605         elif self.mode.name == 'control_pw_pw':
606             self.log_msg('@ enter protection password for "%s":' % self.tile_control_char)
607         elif self.mode.name == 'control_tile_draw':
608             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']))
609         self.input_ = ""
610         self.restore_input_values()
611
612     def set_default_colors(self):
613         curses.init_color(1, 1000, 1000, 1000)
614         curses.init_color(2, 0, 0, 0)
615         self.do_refresh = True
616
617     def set_random_colors(self):
618
619         def rand(offset):
620             import random
621             return int(offset + random.random()*375)
622
623         curses.init_color(1, rand(625), rand(625), rand(625))
624         curses.init_color(2, rand(0), rand(0), rand(0))
625         self.do_refresh = True
626
627     def loop(self, stdscr):
628         import datetime
629
630         def safe_addstr(y, x, line):
631             if y < self.size.y - 1 or x + len(line) < self.size.x:
632                 stdscr.addstr(y, x, line, curses.color_pair(1))
633             else:  # workaround to <https://stackoverflow.com/q/7063128>
634                 cut_i = self.size.x - x - 1
635                 cut = line[:cut_i]
636                 last_char = line[cut_i]
637                 stdscr.addstr(y, self.size.x - 2, last_char, curses.color_pair(1))
638                 stdscr.insstr(y, self.size.x - 2, ' ')
639                 stdscr.addstr(y, x, cut, curses.color_pair(1))
640
641         def handle_input(msg):
642             command, args = self.parser.parse(msg)
643             command(*args)
644
645         def task_action_on(action):
646             return action_tasks[action] in self.game.tasks
647
648         def msg_into_lines_of_width(msg, width):
649             chunk = ''
650             lines = []
651             x = 0
652             for i in range(len(msg)):
653                 if x >= width or msg[i] == "\n":
654                     lines += [chunk]
655                     chunk = ''
656                     x = 0
657                     if msg[i] == "\n":
658                         x -= 1
659                 if msg[i] != "\n":
660                     chunk += msg[i]
661                 x += 1
662             lines += [chunk]
663             return lines
664
665         def reset_screen_size():
666             self.size = YX(*stdscr.getmaxyx())
667             self.size = self.size - YX(self.size.y % 4, 0)
668             self.size = self.size - YX(0, self.size.x % 4)
669             self.window_width = int(self.size.x / 2)
670
671         def recalc_input_lines():
672             if not self.mode.has_input_prompt:
673                 self.input_lines = []
674             else:
675                 self.input_lines = msg_into_lines_of_width(input_prompt + self.input_,
676                                                            self.window_width)
677
678         def move_explorer(direction):
679             target = self.game.map_geometry.move_yx(self.explorer, direction)
680             if target:
681                 self.explorer = target
682                 if self.mode.shows_info:
683                     self.query_info()
684                 if self.tile_draw:
685                     self.send_tile_control_command()
686             else:
687                 self.flash = True
688
689         def draw_history():
690             lines = []
691             for line in self.log:
692                 lines += msg_into_lines_of_width(line, self.window_width)
693             lines.reverse()
694             height_header = 2
695             max_y = self.size.y - len(self.input_lines)
696             for i in range(len(lines)):
697                 if (i >= max_y - height_header):
698                     break
699                 safe_addstr(max_y - i - 1, self.window_width, lines[i])
700
701         def draw_info():
702             if not self.game.turn_complete:
703                 return
704             pos_i = self.explorer.y * self.game.map_geometry.size.x + self.explorer.x
705             info = 'MAP VIEW: %s\n' % self.map_mode
706             if self.game.fov[pos_i] != '.':
707                 info += 'outside field of view'
708             else:
709                 terrain_char = self.game.map_content[pos_i]
710                 terrain_desc = '?'
711                 if terrain_char in self.game.terrains:
712                     terrain_desc = self.game.terrains[terrain_char]
713                 info += 'TERRAIN: "%s" / %s\n' % (terrain_char, terrain_desc)
714                 protection = self.game.map_control_content[pos_i]
715                 if protection == '.':
716                     protection = 'unprotected'
717                 info += 'PROTECTION: %s\n' % protection
718                 for t in self.game.things:
719                     if t.position == self.explorer:
720                         protection = t.protection
721                         if protection == '.':
722                             protection = 'none'
723                         info += 'THING: %s / %s' % (t.type_,
724                                                     self.game.thing_types[t.type_])
725                         if hasattr(t, 'thing_char'):
726                             info += t.thing_char
727                         if hasattr(t, 'name'):
728                             info += ' (%s)' % t.name
729                         info += ' / protection: %s\n' % protection
730                 if self.explorer in self.game.portals:
731                     info += 'PORTAL: ' + self.game.portals[self.explorer] + '\n'
732                 else:
733                     info += 'PORTAL: (none)\n'
734                 if self.explorer in self.game.info_db:
735                     info += 'ANNOTATION: ' + self.game.info_db[self.explorer]
736                 else:
737                     info += 'ANNOTATION: waiting …'
738             lines = msg_into_lines_of_width(info, self.window_width)
739             height_header = 2
740             for i in range(len(lines)):
741                 y = height_header + i
742                 if y >= self.size.y - len(self.input_lines):
743                     break
744                 safe_addstr(y, self.window_width, lines[i])
745
746         def draw_input():
747             y = self.size.y - len(self.input_lines)
748             for i in range(len(self.input_lines)):
749                 safe_addstr(y, self.window_width, self.input_lines[i])
750                 y += 1
751
752         def draw_turn():
753             if not self.game.turn_complete:
754                 return
755             safe_addstr(0, self.window_width, 'TURN: ' + str(self.game.turn))
756
757         def draw_mode():
758             help = "hit [%s] for help" % self.keys['help']
759             if self.mode.has_input_prompt:
760                 help = "enter /help for help"
761             safe_addstr(1, self.window_width,
762                         'MODE: %s – %s' % (self.mode.short_desc, help))
763
764         def draw_map():
765             if not self.game.turn_complete and len(self.map_lines) == 0:
766                 return
767             if self.game.turn_complete:
768                 map_lines_split = []
769                 for y in range(self.game.map_geometry.size.y):
770                     start = self.game.map_geometry.size.x * y
771                     end = start + self.game.map_geometry.size.x
772                     if self.map_mode == 'protections':
773                         map_lines_split += [[c + ' ' for c
774                                              in self.game.map_control_content[start:end]]]
775                     else:
776                         map_lines_split += [[c + ' ' for c
777                                              in self.game.map_content[start:end]]]
778                 if self.map_mode == 'terrain + annotations':
779                     for p in self.game.info_hints:
780                         map_lines_split[p.y][p.x] = 'A '
781                 elif self.map_mode == 'terrain + things':
782                     for p in self.game.portals.keys():
783                         original = map_lines_split[p.y][p.x]
784                         map_lines_split[p.y][p.x] = original[0] + 'P'
785                     used_positions = []
786                     for t in self.game.things:
787                         symbol = self.game.thing_types[t.type_]
788                         meta_char = ' '
789                         if hasattr(t, 'thing_char'):
790                             meta_char = t.thing_char
791                         if t.position in used_positions:
792                             meta_char = '+'
793                         map_lines_split[t.position.y][t.position.x] = symbol + meta_char
794                         used_positions += [t.position]
795                 player = self.game.get_thing(self.game.player_id)
796                 if self.mode.shows_info or self.mode.name == 'control_tile_draw':
797                     map_lines_split[self.explorer.y][self.explorer.x] = '??'
798                 elif self.map_mode != 'terrain + things':
799                     map_lines_split[player.position.y][player.position.x] = '??'
800                 self.map_lines = []
801                 if type(self.game.map_geometry) == MapGeometryHex:
802                     indent = 0
803                     for line in map_lines_split:
804                         self.map_lines += [indent * ' ' + ''.join(line)]
805                         indent = 0 if indent else 1
806                 else:
807                     for line in map_lines_split:
808                         self.map_lines += [''.join(line)]
809                 window_center = YX(int(self.size.y / 2),
810                                    int(self.window_width / 2))
811                 center = player.position
812                 if self.mode.shows_info or self.mode.name == 'control_tile_draw':
813                     center = self.explorer
814                 center = YX(center.y, center.x * 2)
815                 self.offset = center - window_center
816                 if type(self.game.map_geometry) == MapGeometryHex and self.offset.y % 2:
817                     self.offset += YX(0, 1)
818             term_y = max(0, -self.offset.y)
819             term_x = max(0, -self.offset.x)
820             map_y = max(0, self.offset.y)
821             map_x = max(0, self.offset.x)
822             while (term_y < self.size.y and map_y < self.game.map_geometry.size.y):
823                 to_draw = self.map_lines[map_y][map_x:self.window_width + self.offset.x]
824                 safe_addstr(term_y, term_x, to_draw)
825                 term_y += 1
826                 map_y += 1
827
828         def draw_help():
829             content = "%s help\n\n%s\n\n" % (self.mode.short_desc,
830                                              self.mode.help_intro)
831             if len(self.mode.available_actions) > 0:
832                 content += "Available actions:\n"
833                 for action in self.mode.available_actions:
834                     if action in action_tasks:
835                         if action_tasks[action] not in self.game.tasks:
836                             continue
837                     if action == 'move_explorer':
838                         action = 'move'
839                     if action == 'move':
840                         key = ','.join(self.movement_keys)
841                     else:
842                         key = self.keys[action]
843                     content += '[%s] – %s\n' % (key, action_descriptions[action])
844                 content += '\n'
845             if self.mode.name == 'chat':
846                 content += '/nick NAME – re-name yourself to NAME\n'
847                 content += '/%s or /play – switch to play mode\n' % self.keys['switch_to_play']
848                 content += '/%s or /study – switch to study mode\n' % self.keys['switch_to_study']
849                 content += '/%s or /edit – switch to world edit mode\n' % self.keys['switch_to_edit']
850                 content += '/%s or /admin – switch to admin mode\n' % self.keys['switch_to_admin_enter']
851             content += self.mode.list_available_modes(self)
852             for i in range(self.size.y):
853                 safe_addstr(i,
854                             self.window_width * (not self.mode.has_input_prompt),
855                             ' ' * self.window_width)
856             lines = []
857             for line in content.split('\n'):
858                 lines += msg_into_lines_of_width(line, self.window_width)
859             for i in range(len(lines)):
860                 if i >= self.size.y:
861                     break
862                 safe_addstr(i,
863                             self.window_width * (not self.mode.has_input_prompt),
864                             lines[i])
865
866         def draw_screen():
867             stdscr.clear()
868             stdscr.bkgd(' ', curses.color_pair(1))
869             recalc_input_lines()
870             if self.mode.has_input_prompt:
871                 draw_input()
872             if self.mode.shows_info:
873                 draw_info()
874             else:
875                 draw_history()
876             draw_mode()
877             if not self.mode.is_intro:
878                 draw_turn()
879                 draw_map()
880             if self.show_help:
881                 draw_help()
882
883         action_descriptions = {
884             'move': 'move',
885             'flatten': 'flatten surroundings',
886             'teleport': 'teleport',
887             'take_thing': 'pick up thing',
888             'drop_thing': 'drop thing',
889             'toggle_map_mode': 'toggle map view',
890             'toggle_tile_draw': 'toggle protection character drawing',
891             'door': 'open/close',
892             'consume': 'consume',
893         }
894
895         action_tasks = {
896             'flatten': 'FLATTEN_SURROUNDINGS',
897             'take_thing': 'PICK_UP',
898             'drop_thing': 'DROP',
899             'door': 'DOOR',
900             'move': 'MOVE',
901             'consume': 'INTOXICATE',
902         }
903
904         curses.curs_set(False)  # hide cursor
905         curses.start_color()
906         self.set_default_colors()
907         curses.init_pair(1, 1, 2)
908         stdscr.timeout(10)
909         reset_screen_size()
910         self.explorer = YX(0, 0)
911         self.input_ = ''
912         input_prompt = '> '
913         interval = datetime.timedelta(seconds=5)
914         last_ping = datetime.datetime.now() - interval
915         while True:
916             if self.disconnected and self.force_instant_connect:
917                 self.force_instant_connect = False
918                 self.connect()
919             now = datetime.datetime.now()
920             if now - last_ping > interval:
921                 if self.disconnected:
922                     self.connect()
923                 else:
924                     self.send('PING')
925                 last_ping = now
926             if self.flash:
927                 curses.flash()
928                 self.flash = False
929             if self.do_refresh:
930                 draw_screen()
931                 self.do_refresh = False
932             while True:
933                 try:
934                     msg = self.queue.get(block=False)
935                     handle_input(msg)
936                 except queue.Empty:
937                     break
938             try:
939                 key = stdscr.getkey()
940                 self.do_refresh = True
941             except curses.error:
942                 continue
943             self.show_help = False
944             if key == 'KEY_RESIZE':
945                 reset_screen_size()
946             elif self.mode.has_input_prompt and key == 'KEY_BACKSPACE':
947                 self.input_ = self.input_[:-1]
948             elif self.mode.has_input_prompt and key == '\n' and self.input_ == '/help':
949                 self.show_help = True
950                 self.input_ = ""
951                 self.restore_input_values()
952             elif self.mode.has_input_prompt and key != '\n':  # Return key
953                 self.input_ += key
954                 max_length = self.window_width * self.size.y - len(input_prompt) - 1
955                 if len(self.input_) > max_length:
956                     self.input_ = self.input_[:max_length]
957             elif key == self.keys['help'] and not self.mode.is_single_char_entry:
958                 self.show_help = True
959             elif self.mode.name == 'login' and key == '\n':
960                 self.login_name = self.input_
961                 self.send('LOGIN ' + quote(self.input_))
962                 self.input_ = ""
963             elif self.mode.name == 'control_pw_pw' and key == '\n':
964                 if self.input_ == '':
965                     self.log_msg('@ aborted')
966                 else:
967                     self.send('SET_MAP_CONTROL_PASSWORD ' + quote(self.tile_control_char) + ' ' + quote(self.input_))
968                     self.log_msg('@ sent new password for protection character "%s"' % self.tile_control_char)
969                 self.switch_mode('admin')
970             elif self.mode.name == 'password' and key == '\n':
971                 if self.input_ == '':
972                     self.input_ = ' '
973                 self.password = self.input_
974                 self.switch_mode('edit')
975             elif self.mode.name == 'admin_enter' and key == '\n':
976                 self.send('BECOME_ADMIN ' + quote(self.input_))
977                 self.switch_mode('play')
978             elif self.mode.name == 'control_pw_type' and key == '\n':
979                 if len(self.input_) != 1:
980                     self.log_msg('@ entered non-single-char, therefore aborted')
981                     self.switch_mode('admin')
982                 else:
983                     self.tile_control_char = self.input_
984                     self.switch_mode('control_pw_pw')
985             elif self.mode.name == 'admin_thing_protect' and key == '\n':
986                 if len(self.input_) != 1:
987                     self.log_msg('@ entered non-single-char, therefore aborted')
988                 else:
989                     self.send('THING_PROTECTION %s %s' % (self.thing_selected.id_,
990                                                           quote(self.input_)))
991                     self.log_msg('@ sent new protection character for thing')
992                 self.switch_mode('admin')
993             elif self.mode.name == 'control_tile_type' and key == '\n':
994                 if len(self.input_) != 1:
995                     self.log_msg('@ entered non-single-char, therefore aborted')
996                     self.switch_mode('admin')
997                 else:
998                     self.tile_control_char = self.input_
999                     self.switch_mode('control_tile_draw')
1000             elif self.mode.name == 'chat' and key == '\n':
1001                 if self.input_ == '':
1002                     continue
1003                 if self.input_[0] == '/':  # FIXME fails on empty input
1004                     if self.input_ in {'/' + self.keys['switch_to_play'], '/play'}:
1005                         self.switch_mode('play')
1006                     elif self.input_ in {'/' + self.keys['switch_to_study'], '/study'}:
1007                         self.switch_mode('study')
1008                     elif self.input_ in {'/' + self.keys['switch_to_edit'], '/edit'}:
1009                         self.switch_mode('edit')
1010                     elif self.input_ in {'/' + self.keys['switch_to_admin_enter'], '/admin'}:
1011                         self.switch_mode('admin_enter')
1012                     elif self.input_.startswith('/nick'):
1013                         tokens = self.input_.split(maxsplit=1)
1014                         if len(tokens) == 2:
1015                             self.send('NICK ' + quote(tokens[1]))
1016                         else:
1017                             self.log_msg('? need login name')
1018                     else:
1019                         self.log_msg('? unknown command')
1020                 else:
1021                     self.send('ALL ' + quote(self.input_))
1022                 self.input_ = ""
1023             elif self.mode.name == 'name_thing' and key == '\n':
1024                 if self.input_ == '':
1025                     self.input_ = ' '
1026                 self.send('THING_NAME %s %s %s' % (self.thing_selected.id_,
1027                                                    quote(self.input_),
1028                                                    quote(self.password)))
1029                 self.switch_mode('edit')
1030             elif self.mode.name == 'annotate' and key == '\n':
1031                 if self.input_ == '':
1032                     self.input_ = ' '
1033                 self.send('ANNOTATE %s %s %s' % (self.explorer, quote(self.input_),
1034                                                  quote(self.password)))
1035                 self.switch_mode('edit')
1036             elif self.mode.name == 'portal' and key == '\n':
1037                 if self.input_ == '':
1038                     self.input_ = ' '
1039                 self.send('PORTAL %s %s %s' % (self.explorer, quote(self.input_),
1040                                                quote(self.password)))
1041                 self.switch_mode('edit')
1042             elif self.mode.name == 'study':
1043                 if self.mode.mode_switch_on_key(self, key):
1044                     continue
1045                 elif key == self.keys['toggle_map_mode']:
1046                     self.toggle_map_mode()
1047                 elif key in self.movement_keys:
1048                     move_explorer(self.movement_keys[key])
1049             elif self.mode.name == 'play':
1050                 if self.mode.mode_switch_on_key(self, key):
1051                     continue
1052                 elif key == self.keys['take_thing'] and task_action_on('take_thing'):
1053                     self.send('TASK:PICK_UP')
1054                 elif key == self.keys['drop_thing'] and task_action_on('drop_thing'):
1055                     self.send('TASK:DROP')
1056                 elif key == self.keys['door'] and task_action_on('door'):
1057                     self.send('TASK:DOOR')
1058                 elif key == self.keys['consume'] and task_action_on('consume'):
1059                     self.send('TASK:INTOXICATE')
1060                 elif key == self.keys['teleport']:
1061                     player = self.game.get_thing(self.game.player_id)
1062                     if player.position in self.game.portals:
1063                         self.host = self.game.portals[player.position]
1064                         self.reconnect()
1065                     else:
1066                         self.flash = True
1067                         self.log_msg('? not standing on portal')
1068                 elif key in self.movement_keys and task_action_on('move'):
1069                     self.send('TASK:MOVE ' + self.movement_keys[key])
1070             elif self.mode.name == 'write':
1071                 self.send('TASK:WRITE %s %s' % (key, quote(self.password)))
1072                 self.switch_mode('edit')
1073             elif self.mode.name == 'control_tile_draw':
1074                 if self.mode.mode_switch_on_key(self, key):
1075                     continue
1076                 elif key in self.movement_keys:
1077                     move_explorer(self.movement_keys[key])
1078                 elif key == self.keys['toggle_tile_draw']:
1079                     self.tile_draw = False if self.tile_draw else True
1080             elif self.mode.name == 'admin':
1081                 if self.mode.mode_switch_on_key(self, key):
1082                     continue
1083                 elif key in self.movement_keys and task_action_on('move'):
1084                     self.send('TASK:MOVE ' + self.movement_keys[key])
1085             elif self.mode.name == 'edit':
1086                 if self.mode.mode_switch_on_key(self, key):
1087                     continue
1088                 elif key == self.keys['flatten'] and task_action_on('flatten'):
1089                     self.send('TASK:FLATTEN_SURROUNDINGS ' + quote(self.password))
1090                 elif key == self.keys['toggle_map_mode']:
1091                     self.toggle_map_mode()
1092                 elif key in self.movement_keys and task_action_on('move'):
1093                     self.send('TASK:MOVE ' + self.movement_keys[key])
1094
1095 if len(sys.argv) != 2:
1096     raise ArgError('wrong number of arguments, need game host')
1097 host = sys.argv[1]
1098 TUI(host)