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