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