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