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