home · contact · privacy
Minor client mode switch refactor.
[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             selectables = [t for t in self.game.things
629                            if t != player and t.type_ != 'Player'
630                            and t.position == player.position]
631             if len(selectables) == 0:
632                 self.log_msg('none')
633             else:
634                 for t in selectables:
635                     self.log_msg(str(t.id_) + ' ' + self.get_thing_info(t))
636         elif self.mode.name == 'command_thing':
637             self.send('TASK:COMMAND ' + quote('HELP'))
638         elif self.mode.name == 'control_pw_pw':
639             self.log_msg('@ enter protection password for "%s":' % self.tile_control_char)
640         elif self.mode.name == 'control_tile_draw':
641             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']))
642         self.input_ = ""
643         self.restore_input_values()
644
645     def set_default_colors(self):
646         curses.init_color(1, 1000, 1000, 1000)
647         curses.init_color(2, 0, 0, 0)
648         self.do_refresh = True
649
650     def set_random_colors(self):
651
652         def rand(offset):
653             import random
654             return int(offset + random.random()*375)
655
656         curses.init_color(1, rand(625), rand(625), rand(625))
657         curses.init_color(2, rand(0), rand(0), rand(0))
658         self.do_refresh = True
659
660     def get_info(self):
661         if self.info_cached:
662             return self.info_cached
663         pos_i = self.explorer.y * self.game.map_geometry.size.x + self.explorer.x
664         info_to_cache = ''
665         if len(self.game.fov) > pos_i and self.game.fov[pos_i] != '.':
666             info_to_cache += 'outside field of view'
667         else:
668             terrain_char = self.game.map_content[pos_i]
669             terrain_desc = '?'
670             if terrain_char in self.game.terrains:
671                 terrain_desc = self.game.terrains[terrain_char]
672             info_to_cache += 'TERRAIN: "%s" / %s\n' % (terrain_char,
673                                                        terrain_desc)
674             protection = self.game.map_control_content[pos_i]
675             if protection == '.':
676                 protection = 'unprotected'
677             info_to_cache += 'PROTECTION: %s\n' % protection
678             for t in self.game.things:
679                 if t.position == self.explorer:
680                     info_to_cache += 'THING: %s' % self.get_thing_info(t)
681                     protection = t.protection
682                     if protection == '.':
683                         protection = 'none'
684                     info_to_cache += ' / protection: %s\n' % protection
685             if self.explorer in self.game.portals:
686                 info_to_cache += 'PORTAL: ' +\
687                     self.game.portals[self.explorer] + '\n'
688             else:
689                 info_to_cache += 'PORTAL: (none)\n'
690             if self.explorer in self.game.annotations:
691                 info_to_cache += 'ANNOTATION: ' +\
692                     self.game.annotations[self.explorer]
693         self.info_cached = info_to_cache
694         return self.info_cached
695
696     def get_thing_info(self, t):
697         info = '%s / %s' %\
698             (t.type_, self.game.thing_types[t.type_])
699         if hasattr(t, 'thing_char'):
700             info += t.thing_char
701         if hasattr(t, 'name'):
702             info += ' (%s)' % t.name
703         return info
704
705     def loop(self, stdscr):
706         import datetime
707
708         def safe_addstr(y, x, line):
709             if y < self.size.y - 1 or x + len(line) < self.size.x:
710                 stdscr.addstr(y, x, line, curses.color_pair(1))
711             else:  # workaround to <https://stackoverflow.com/q/7063128>
712                 cut_i = self.size.x - x - 1
713                 cut = line[:cut_i]
714                 last_char = line[cut_i]
715                 stdscr.addstr(y, self.size.x - 2, last_char, curses.color_pair(1))
716                 stdscr.insstr(y, self.size.x - 2, ' ')
717                 stdscr.addstr(y, x, cut, curses.color_pair(1))
718
719         def handle_input(msg):
720             command, args = self.parser.parse(msg)
721             command(*args)
722
723         def task_action_on(action):
724             return action_tasks[action] in self.game.tasks
725
726         def msg_into_lines_of_width(msg, width):
727             chunk = ''
728             lines = []
729             x = 0
730             for i in range(len(msg)):
731                 if x >= width or msg[i] == "\n":
732                     lines += [chunk]
733                     chunk = ''
734                     x = 0
735                     if msg[i] == "\n":
736                         x -= 1
737                 if msg[i] != "\n":
738                     chunk += msg[i]
739                 x += 1
740             lines += [chunk]
741             return lines
742
743         def reset_screen_size():
744             self.size = YX(*stdscr.getmaxyx())
745             self.size = self.size - YX(self.size.y % 4, 0)
746             self.size = self.size - YX(0, self.size.x % 4)
747             self.window_width = int(self.size.x / 2)
748
749         def recalc_input_lines():
750             if not self.mode.has_input_prompt:
751                 self.input_lines = []
752             else:
753                 self.input_lines = msg_into_lines_of_width(input_prompt + self.input_,
754                                                            self.window_width)
755
756         def move_explorer(direction):
757             target = self.game.map_geometry.move_yx(self.explorer, direction)
758             if target:
759                 self.info_cached = None
760                 self.explorer = target
761                 if self.tile_draw:
762                     self.send_tile_control_command()
763             else:
764                 self.flash = True
765
766         def draw_history():
767             lines = []
768             for line in self.log:
769                 lines += msg_into_lines_of_width(line, self.window_width)
770             lines.reverse()
771             height_header = 2
772             max_y = self.size.y - len(self.input_lines)
773             for i in range(len(lines)):
774                 if (i >= max_y - height_header):
775                     break
776                 safe_addstr(max_y - i - 1, self.window_width, lines[i])
777
778         def draw_info():
779             info = 'MAP VIEW: %s\n%s' % (self.map_mode, self.get_info())
780             lines = msg_into_lines_of_width(info, self.window_width)
781             height_header = 2
782             for i in range(len(lines)):
783                 y = height_header + i
784                 if y >= self.size.y - len(self.input_lines):
785                     break
786                 safe_addstr(y, self.window_width, lines[i])
787
788         def draw_input():
789             y = self.size.y - len(self.input_lines)
790             for i in range(len(self.input_lines)):
791                 safe_addstr(y, self.window_width, self.input_lines[i])
792                 y += 1
793
794         def draw_turn():
795             if not self.game.turn_complete:
796                 return
797             safe_addstr(0, self.window_width, 'TURN: ' + str(self.game.turn))
798
799         def draw_mode():
800             help = "hit [%s] for help" % self.keys['help']
801             if self.mode.has_input_prompt:
802                 help = "enter /help for help"
803             safe_addstr(1, self.window_width,
804                         'MODE: %s – %s' % (self.mode.short_desc, help))
805
806         def draw_map():
807             if not self.game.turn_complete and len(self.map_lines) == 0:
808                 return
809             if self.game.turn_complete:
810                 map_lines_split = []
811                 for y in range(self.game.map_geometry.size.y):
812                     start = self.game.map_geometry.size.x * y
813                     end = start + self.game.map_geometry.size.x
814                     if self.map_mode == 'protections':
815                         map_lines_split += [[c + ' ' for c
816                                              in self.game.map_control_content[start:end]]]
817                     else:
818                         map_lines_split += [[c + ' ' for c
819                                              in self.game.map_content[start:end]]]
820                 if self.map_mode == 'terrain + annotations':
821                     for p in self.game.annotations:
822                         map_lines_split[p.y][p.x] = 'A '
823                 elif self.map_mode == 'terrain + things':
824                     for p in self.game.portals.keys():
825                         original = map_lines_split[p.y][p.x]
826                         map_lines_split[p.y][p.x] = original[0] + 'P'
827                     used_positions = []
828
829                     def draw_thing(t, used_positions):
830                         symbol = self.game.thing_types[t.type_]
831                         meta_char = ' '
832                         if hasattr(t, 'thing_char'):
833                             meta_char = t.thing_char
834                         if t.position in used_positions:
835                             meta_char = '+'
836                         map_lines_split[t.position.y][t.position.x] = symbol + meta_char
837                         used_positions += [t.position]
838
839                     for t in [t for t in self.game.things if t.type_ != 'Player']:
840                         draw_thing(t, used_positions)
841                     for t in [t for t in self.game.things if t.type_ == 'Player']:
842                         draw_thing(t, used_positions)
843                 player = self.game.get_thing(self.game.player_id)
844                 if self.mode.shows_info or self.mode.name == 'control_tile_draw':
845                     map_lines_split[self.explorer.y][self.explorer.x] = '??'
846                 elif self.map_mode != 'terrain + things':
847                     map_lines_split[player.position.y][player.position.x] = '??'
848                 self.map_lines = []
849                 if type(self.game.map_geometry) == MapGeometryHex:
850                     indent = 0
851                     for line in map_lines_split:
852                         self.map_lines += [indent * ' ' + ''.join(line)]
853                         indent = 0 if indent else 1
854                 else:
855                     for line in map_lines_split:
856                         self.map_lines += [''.join(line)]
857                 window_center = YX(int(self.size.y / 2),
858                                    int(self.window_width / 2))
859                 center = player.position
860                 if self.mode.shows_info or self.mode.name == 'control_tile_draw':
861                     center = self.explorer
862                 center = YX(center.y, center.x * 2)
863                 self.offset = center - window_center
864                 if type(self.game.map_geometry) == MapGeometryHex and self.offset.y % 2:
865                     self.offset += YX(0, 1)
866             term_y = max(0, -self.offset.y)
867             term_x = max(0, -self.offset.x)
868             map_y = max(0, self.offset.y)
869             map_x = max(0, self.offset.x)
870             while (term_y < self.size.y and map_y < self.game.map_geometry.size.y):
871                 to_draw = self.map_lines[map_y][map_x:self.window_width + self.offset.x]
872                 safe_addstr(term_y, term_x, to_draw)
873                 term_y += 1
874                 map_y += 1
875
876         def draw_help():
877             content = "%s help\n\n%s\n\n" % (self.mode.short_desc,
878                                              self.mode.help_intro)
879             if len(self.mode.available_actions) > 0:
880                 content += "Available actions:\n"
881                 for action in self.mode.available_actions:
882                     if action in action_tasks:
883                         if action_tasks[action] not in self.game.tasks:
884                             continue
885                     if action == 'move_explorer':
886                         action = 'move'
887                     if action == 'move':
888                         key = ','.join(self.movement_keys)
889                     else:
890                         key = self.keys[action]
891                     content += '[%s] – %s\n' % (key, action_descriptions[action])
892                 content += '\n'
893             if self.mode.name == 'chat':
894                 content += '/nick NAME – re-name yourself to NAME\n'
895                 content += '/%s or /play – switch to play mode\n' % self.keys['switch_to_play']
896                 content += '/%s or /study – switch to study mode\n' % self.keys['switch_to_study']
897                 content += '/%s or /edit – switch to world edit mode\n' % self.keys['switch_to_edit']
898                 content += '/%s or /admin – switch to admin mode\n' % self.keys['switch_to_admin_enter']
899             content += self.mode.list_available_modes(self)
900             for i in range(self.size.y):
901                 safe_addstr(i,
902                             self.window_width * (not self.mode.has_input_prompt),
903                             ' ' * self.window_width)
904             lines = []
905             for line in content.split('\n'):
906                 lines += msg_into_lines_of_width(line, self.window_width)
907             for i in range(len(lines)):
908                 if i >= self.size.y:
909                     break
910                 safe_addstr(i,
911                             self.window_width * (not self.mode.has_input_prompt),
912                             lines[i])
913
914         def draw_screen():
915             stdscr.clear()
916             stdscr.bkgd(' ', curses.color_pair(1))
917             recalc_input_lines()
918             if self.mode.has_input_prompt:
919                 draw_input()
920             if self.mode.shows_info:
921                 draw_info()
922             else:
923                 draw_history()
924             draw_mode()
925             if not self.mode.is_intro:
926                 draw_turn()
927                 draw_map()
928             if self.show_help:
929                 draw_help()
930
931         action_descriptions = {
932             'move': 'move',
933             'flatten': 'flatten surroundings',
934             'teleport': 'teleport',
935             'take_thing': 'pick up thing',
936             'drop_thing': 'drop thing',
937             'toggle_map_mode': 'toggle map view',
938             'toggle_tile_draw': 'toggle protection character drawing',
939             'door': 'open/close',
940             'consume': 'consume',
941         }
942
943         action_tasks = {
944             'flatten': 'FLATTEN_SURROUNDINGS',
945             'take_thing': 'PICK_UP',
946             'drop_thing': 'DROP',
947             'door': 'DOOR',
948             'move': 'MOVE',
949             'command': 'COMMAND',
950             'consume': 'INTOXICATE',
951         }
952
953         curses.curs_set(False)  # hide cursor
954         curses.start_color()
955         self.set_default_colors()
956         curses.init_pair(1, 1, 2)
957         stdscr.timeout(10)
958         reset_screen_size()
959         self.explorer = YX(0, 0)
960         self.input_ = ''
961         input_prompt = '> '
962         interval = datetime.timedelta(seconds=5)
963         last_ping = datetime.datetime.now() - interval
964         while True:
965             if self.disconnected and self.force_instant_connect:
966                 self.force_instant_connect = False
967                 self.connect()
968             now = datetime.datetime.now()
969             if now - last_ping > interval:
970                 if self.disconnected:
971                     self.connect()
972                 else:
973                     self.send('PING')
974                 last_ping = now
975             if self.flash:
976                 curses.flash()
977                 self.flash = False
978             if self.do_refresh:
979                 draw_screen()
980                 self.do_refresh = False
981             while True:
982                 try:
983                     msg = self.queue.get(block=False)
984                     handle_input(msg)
985                 except queue.Empty:
986                     break
987             try:
988                 key = stdscr.getkey()
989                 self.do_refresh = True
990             except curses.error:
991                 continue
992             self.show_help = False
993             if key == 'KEY_RESIZE':
994                 reset_screen_size()
995             elif self.mode.has_input_prompt and key == 'KEY_BACKSPACE':
996                 self.input_ = self.input_[:-1]
997             elif self.mode.has_input_prompt and key == '\n' and self.input_ == '/help':
998                 self.show_help = True
999                 self.input_ = ""
1000                 self.restore_input_values()
1001             elif self.mode.has_input_prompt and key != '\n':  # Return key
1002                 self.input_ += key
1003                 max_length = self.window_width * self.size.y - len(input_prompt) - 1
1004                 if len(self.input_) > max_length:
1005                     self.input_ = self.input_[:max_length]
1006             elif key == self.keys['help'] and not self.mode.is_single_char_entry:
1007                 self.show_help = True
1008             elif self.mode.name == 'login' and key == '\n':
1009                 self.login_name = self.input_
1010                 self.send('LOGIN ' + quote(self.input_))
1011                 self.input_ = ""
1012             elif self.mode.name == 'take_thing' and key == '\n':
1013                 if self.input_ == '':
1014                     self.log_msg('@ aborted')
1015                 else:
1016                     self.send('TASK:PICK_UP ' + quote(self.input_))
1017                 self.input_ = ''
1018                 self.switch_mode('play')
1019             elif self.mode.name == 'command_thing' and key == '\n':
1020                 if self.input_ == '':
1021                     self.log_msg('@ aborted')
1022                     self.switch_mode('play')
1023                 elif task_action_on('command'):
1024                     self.send('TASK:COMMAND ' + quote(self.input_))
1025                     self.input_ = ""
1026             elif self.mode.name == 'control_pw_pw' and key == '\n':
1027                 if self.input_ == '':
1028                     self.log_msg('@ aborted')
1029                 else:
1030                     self.send('SET_MAP_CONTROL_PASSWORD ' + quote(self.tile_control_char) + ' ' + quote(self.input_))
1031                     self.log_msg('@ sent new password for protection character "%s"' % self.tile_control_char)
1032                 self.switch_mode('admin')
1033             elif self.mode.name == 'password' and key == '\n':
1034                 if self.input_ == '':
1035                     self.input_ = ' '
1036                 self.password = self.input_
1037                 self.switch_mode('edit')
1038             elif self.mode.name == 'admin_enter' and key == '\n':
1039                 self.send('BECOME_ADMIN ' + quote(self.input_))
1040                 self.switch_mode('play')
1041             elif self.mode.name == 'control_pw_type' and key == '\n':
1042                 if len(self.input_) != 1:
1043                     self.log_msg('@ entered non-single-char, therefore aborted')
1044                     self.switch_mode('admin')
1045                 else:
1046                     self.tile_control_char = self.input_
1047                     self.switch_mode('control_pw_pw')
1048             elif self.mode.name == 'admin_thing_protect' and key == '\n':
1049                 if len(self.input_) != 1:
1050                     self.log_msg('@ entered non-single-char, therefore aborted')
1051                 else:
1052                     self.send('THING_PROTECTION %s %s' % (self.thing_selected.id_,
1053                                                           quote(self.input_)))
1054                     self.log_msg('@ sent new protection character for thing')
1055                 self.switch_mode('admin')
1056             elif self.mode.name == 'control_tile_type' and key == '\n':
1057                 if len(self.input_) != 1:
1058                     self.log_msg('@ entered non-single-char, therefore aborted')
1059                     self.switch_mode('admin')
1060                 else:
1061                     self.tile_control_char = self.input_
1062                     self.switch_mode('control_tile_draw')
1063             elif self.mode.name == 'chat' and key == '\n':
1064                 if self.input_ == '':
1065                     continue
1066                 if self.input_[0] == '/':  # FIXME fails on empty input
1067                     if self.input_ in {'/' + self.keys['switch_to_play'], '/play'}:
1068                         self.switch_mode('play')
1069                     elif self.input_ in {'/' + self.keys['switch_to_study'], '/study'}:
1070                         self.switch_mode('study')
1071                     elif self.input_ in {'/' + self.keys['switch_to_edit'], '/edit'}:
1072                         self.switch_mode('edit')
1073                     elif self.input_ in {'/' + self.keys['switch_to_admin_enter'], '/admin'}:
1074                         self.switch_mode('admin_enter')
1075                     elif self.input_.startswith('/nick'):
1076                         tokens = self.input_.split(maxsplit=1)
1077                         if len(tokens) == 2:
1078                             self.send('NICK ' + quote(tokens[1]))
1079                         else:
1080                             self.log_msg('? need login name')
1081                     else:
1082                         self.log_msg('? unknown command')
1083                 else:
1084                     self.send('ALL ' + quote(self.input_))
1085                 self.input_ = ""
1086             elif self.mode.name == 'name_thing' and key == '\n':
1087                 if self.input_ == '':
1088                     self.input_ = ' '
1089                 self.send('THING_NAME %s %s %s' % (self.thing_selected.id_,
1090                                                    quote(self.input_),
1091                                                    quote(self.password)))
1092                 self.switch_mode('edit')
1093             elif self.mode.name == 'annotate' and key == '\n':
1094                 if self.input_ == '':
1095                     self.input_ = ' '
1096                 self.send('ANNOTATE %s %s %s' % (self.explorer, quote(self.input_),
1097                                                  quote(self.password)))
1098                 self.switch_mode('edit')
1099             elif self.mode.name == 'portal' and key == '\n':
1100                 if self.input_ == '':
1101                     self.input_ = ' '
1102                 self.send('PORTAL %s %s %s' % (self.explorer, quote(self.input_),
1103                                                quote(self.password)))
1104                 self.switch_mode('edit')
1105             elif self.mode.name == 'study':
1106                 if self.mode.mode_switch_on_key(self, key):
1107                     continue
1108                 elif key == self.keys['toggle_map_mode']:
1109                     self.toggle_map_mode()
1110                 elif key in self.movement_keys:
1111                     move_explorer(self.movement_keys[key])
1112             elif self.mode.name == 'play':
1113                 if self.mode.mode_switch_on_key(self, key):
1114                     continue
1115                 elif key == self.keys['drop_thing'] and task_action_on('drop_thing'):
1116                     self.send('TASK:DROP')
1117                 elif key == self.keys['door'] and task_action_on('door'):
1118                     self.send('TASK:DOOR')
1119                 elif key == self.keys['consume'] and task_action_on('consume'):
1120                     self.send('TASK:INTOXICATE')
1121                 elif key == self.keys['teleport']:
1122                     player = self.game.get_thing(self.game.player_id)
1123                     if player.position in self.game.portals:
1124                         self.host = self.game.portals[player.position]
1125                         self.reconnect()
1126                     else:
1127                         self.flash = True
1128                         self.log_msg('? not standing on portal')
1129                 elif key in self.movement_keys and task_action_on('move'):
1130                     self.send('TASK:MOVE ' + self.movement_keys[key])
1131             elif self.mode.name == 'write':
1132                 self.send('TASK:WRITE %s %s' % (key, quote(self.password)))
1133                 self.switch_mode('edit')
1134             elif self.mode.name == 'control_tile_draw':
1135                 if self.mode.mode_switch_on_key(self, key):
1136                     continue
1137                 elif key in self.movement_keys:
1138                     move_explorer(self.movement_keys[key])
1139                 elif key == self.keys['toggle_tile_draw']:
1140                     self.tile_draw = False if self.tile_draw else True
1141             elif self.mode.name == 'admin':
1142                 if self.mode.mode_switch_on_key(self, key):
1143                     continue
1144                 elif key in self.movement_keys and task_action_on('move'):
1145                     self.send('TASK:MOVE ' + self.movement_keys[key])
1146             elif self.mode.name == 'edit':
1147                 if self.mode.mode_switch_on_key(self, key):
1148                     continue
1149                 elif key == self.keys['flatten'] and task_action_on('flatten'):
1150                     self.send('TASK:FLATTEN_SURROUNDINGS ' + quote(self.password))
1151                 elif key == self.keys['toggle_map_mode']:
1152                     self.toggle_map_mode()
1153                 elif key in self.movement_keys and task_action_on('move'):
1154                     self.send('TASK:MOVE ' + self.movement_keys[key])
1155
1156 if len(sys.argv) != 2:
1157     raise ArgError('wrong number of arguments, need game host')
1158 host = sys.argv[1]
1159 TUI(host)