home · contact · privacy
Fix sign editing bugs.
[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             time.sleep(0.1)  # give potential SSL negotation some time …
627             self.socket.send('TASKS')
628             self.socket.send('TERRAINS')
629             self.socket.send('THING_TYPES')
630             self.switch_mode('login')
631         except ConnectionRefusedError:
632             self.log_msg('@ server connect failure')
633             self.disconnected = True
634             self.switch_mode('waiting_for_server')
635         self.do_refresh = True
636
637     def reconnect(self):
638         self.log_msg('@ attempting reconnect')
639         self.send('QUIT')
640         # necessitated by some strange SSL race conditions with ws4py
641         time.sleep(0.1)  # FIXME find out why exactly necessary
642         self.switch_mode('waiting_for_server')
643         self.connect()
644
645     def send(self, msg):
646         try:
647             if hasattr(self.socket, 'plom_closed') and self.socket.plom_closed:
648                 raise BrokenSocketConnection
649             self.socket.send(msg)
650         except (BrokenPipeError, BrokenSocketConnection):
651             self.log_msg('@ server disconnected :(')
652             self.disconnected = True
653             self.force_instant_connect = True
654             self.do_refresh = True
655
656     def log_msg(self, msg):
657         self.log += [msg]
658         if len(self.log) > 100:
659             self.log = self.log[-100:]
660
661     def restore_input_values(self):
662         if self.mode.name == 'annotate' and self.explorer in self.game.annotations:
663             self.input_ = self.game.annotations[self.explorer]
664         elif self.mode.name == 'portal' and self.explorer in self.game.portals:
665             self.input_ = self.game.portals[self.explorer]
666         elif self.mode.name == 'password':
667             self.input_ = self.password
668         elif self.mode.name == 'name_thing':
669             if hasattr(self.game.player.carrying, 'name'):
670                 self.input_ = self.game.player.carrying.name
671         elif self.mode.name == 'admin_thing_protect':
672             if hasattr(self.game.player.carrying, 'protection'):
673                 self.input_ = self.game.player.carrying.protection
674         elif self.mode.name == 'enter_face':
675             start = self.ascii_draw_stage * 6
676             end = (self.ascii_draw_stage + 1) * 6
677             self.input_ = self.game.player.face[start:end]
678         elif self.mode.name == 'enter_design':
679             width = self.game.player.carrying.design[0].x
680             start = self.ascii_draw_stage * width
681             end = (self.ascii_draw_stage + 1) * width
682             self.input_ = self.game.player.carrying.design[1][start:end]
683
684     def send_tile_control_command(self):
685         self.send('SET_TILE_CONTROL %s %s' %
686                   (self.explorer, quote(self.tile_control_char)))
687
688     def toggle_map_mode(self):
689         if self.map_mode == 'terrain only':
690             self.map_mode = 'terrain + annotations'
691         elif self.map_mode == 'terrain + annotations':
692             self.map_mode = 'terrain + things'
693         elif self.map_mode == 'terrain + things':
694             self.map_mode = 'protections'
695         elif self.map_mode == 'protections':
696             self.map_mode = 'terrain only'
697
698     def switch_mode(self, mode_name):
699
700         def fail(msg, return_mode='play'):
701             self.log_msg('? ' + msg)
702             self.flash = True
703             self.switch_mode(return_mode)
704
705         if self.mode and self.mode.name == 'control_tile_draw':
706             self.log_msg('@ finished tile protection drawing.')
707         self.draw_face = False
708         self.tile_draw = False
709         self.ascii_draw_stage = 0
710         self.full_ascii_draw = ''
711         if mode_name == 'command_thing' and\
712            (not self.game.player.carrying or
713             not self.game.player.carrying.commandable):
714             return fail('not carrying anything commandable')
715         if mode_name == 'name_thing' and not self.game.player.carrying:
716             return fail('not carrying anything to re-name', 'edit')
717         if mode_name == 'admin_thing_protect' and not self.game.player.carrying:
718             return fail('not carrying anything to protect')
719         if mode_name == 'take_thing' and self.game.player.carrying:
720             return fail('already carrying something')
721         if mode_name == 'drop_thing' and not self.game.player.carrying:
722             return fail('not carrying anything droppable')
723         if mode_name == 'enter_design' and\
724            (not self.game.player.carrying or
725             not hasattr(self.game.player.carrying, 'design')):
726             return fail('not carrying designable to edit', 'edit')
727         if mode_name == 'admin_enter' and self.is_admin:
728             mode_name = 'admin'
729         self.mode = getattr(self, 'mode_' + mode_name)
730         if self.mode.name in {'control_tile_draw', 'control_tile_type',
731                               'control_pw_type'}:
732             self.map_mode = 'protections'
733         elif self.mode.name != 'edit':
734             self.map_mode = 'terrain + things'
735         if self.mode.shows_info or self.mode.name == 'control_tile_draw':
736             self.explorer = YX(self.game.player.position.y,
737                                self.game.player.position.x)
738         if self.mode.is_single_char_entry:
739             self.show_help = True
740         if len(self.mode.intro_msg) > 0:
741             self.log_msg(self.mode.intro_msg)
742         if self.mode.name == 'login':
743             if self.login_name:
744                 self.send('LOGIN ' + quote(self.login_name))
745             else:
746                 self.log_msg('@ enter username')
747         elif self.mode.name == 'take_thing':
748             self.log_msg('Portable things in reach for pick-up:')
749             directed_moves = {
750                 'HERE': YX(0, 0), 'LEFT': YX(0, -1), 'RIGHT': YX(0, 1)
751             }
752             if type(self.game.map_geometry) == MapGeometrySquare:
753                 directed_moves['UP'] = YX(-1, 0)
754                 directed_moves['DOWN'] = YX(1, 0)
755             elif type(self.game.map_geometry) == MapGeometryHex:
756                 if self.game.player.position.y % 2:
757                     directed_moves['UPLEFT'] = YX(-1, 0)
758                     directed_moves['UPRIGHT'] = YX(-1, 1)
759                     directed_moves['DOWNLEFT'] = YX(1, 0)
760                     directed_moves['DOWNRIGHT'] = YX(1, 1)
761                 else:
762                     directed_moves['UPLEFT'] = YX(-1, -1)
763                     directed_moves['UPRIGHT'] = YX(-1, 0)
764                     directed_moves['DOWNLEFT'] = YX(1, -1)
765                     directed_moves['DOWNRIGHT'] = YX(1, 0)
766             select_range = {}
767             for direction in directed_moves:
768                 move = directed_moves[direction]
769                 select_range[direction] = self.game.player.position + move
770             self.selectables = []
771             directions = []
772             for direction in select_range:
773                 for t in [t for t in self.game.things
774                           if t.portable and t.position == select_range[direction]]:
775                     self.selectables += [t.id_]
776                     directions += [direction]
777             if len(self.selectables) == 0:
778                 return fail('nothing to pick-up')
779             else:
780                 for i in range(len(self.selectables)):
781                     t = self.game.get_thing(self.selectables[i])
782                     self.log_msg('%s %s: %s' % (i, directions[i],
783                                                 self.get_thing_info(t)))
784         elif self.mode.name == 'drop_thing':
785             self.log_msg('Direction to drop thing to:')
786             self.selectables =\
787                 ['HERE'] + list(self.game.tui.movement_keys.values())
788             for i in range(len(self.selectables)):
789                 self.log_msg(str(i) + ': ' + self.selectables[i])
790         elif self.mode.name == 'enter_design':
791             if self.game.player.carrying.type_ == 'Hat':
792                 self.log_msg('@ The design you enter must be %s lines of max %s '
793                              'characters width each'
794                              % (self.game.player.carrying.design[0].y,
795                                 self.game.player.carrying.design[0].x))
796                 self.log_msg('@ Legal characters: ' + self.game.players_hat_chars)
797                 self.log_msg('@ (Eat cookies to extend the ASCII characters available for drawing.)')
798             else:
799                 self.log_msg('@ Width of first line determines maximum width for remaining design')
800                 self.log_msg('@ Finish design by entering an empty line (multiple space characters do not count as empty)')
801         elif self.mode.name == 'command_thing':
802             self.send('TASK:COMMAND ' + quote('HELP'))
803         elif self.mode.name == 'control_pw_pw':
804             self.log_msg('@ enter protection password for "%s":' % self.tile_control_char)
805         elif self.mode.name == 'control_tile_draw':
806             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']))
807         self.input_ = ""
808         self.restore_input_values()
809
810     def set_default_colors(self):
811         curses.init_color(1, 1000, 1000, 1000)
812         curses.init_color(2, 0, 0, 0)
813         self.do_refresh = True
814
815     def set_random_colors(self):
816
817         def rand(offset):
818             import random
819             return int(offset + random.random()*375)
820
821         curses.init_color(1, rand(625), rand(625), rand(625))
822         curses.init_color(2, rand(0), rand(0), rand(0))
823         self.do_refresh = True
824
825     def get_info(self):
826         if self.info_cached:
827             return self.info_cached
828         pos_i = self.explorer.y * self.game.map_geometry.size.x + self.explorer.x
829         info_to_cache = ''
830         if len(self.game.fov) > pos_i and self.game.fov[pos_i] != '.':
831             info_to_cache += 'outside field of view'
832         else:
833             for t in self.game.things:
834                 if t.position == self.explorer:
835                     info_to_cache += '%s' % self.get_thing_info(t, True)
836             terrain_char = self.game.map_content[pos_i]
837             terrain_desc = '?'
838             if terrain_char in self.game.terrains:
839                 terrain_desc = self.game.terrains[terrain_char]
840             info_to_cache += 'TERRAIN: %s (%s' % (terrain_char,
841                                                        terrain_desc)
842             protection = self.game.map_control_content[pos_i]
843             if protection != '.':
844                 info_to_cache += '/protection:%s' % protection
845             info_to_cache += ')\n'
846             if self.explorer in self.game.portals:
847                 info_to_cache += 'PORTAL: ' +\
848                     self.game.portals[self.explorer] + '\n'
849             if self.explorer in self.game.annotations:
850                 info_to_cache += 'ANNOTATION: ' +\
851                     self.game.annotations[self.explorer]
852         self.info_cached = info_to_cache
853         return self.info_cached
854
855     def get_thing_info(self, t, detailed=False):
856         info = ''
857         if detailed:
858             info += '- '
859         info += self.game.thing_types[t.type_]
860         if hasattr(t, 'thing_char'):
861             info += t.thing_char
862         if hasattr(t, 'name'):
863             info += ': %s' % t.name
864         info += ' (%s' % t.type_
865         if hasattr(t, 'installed'):
866             info += '/installed'
867         if t.type_ == 'Bottle':
868             if t.thing_char == '_':
869                 info += '/empty'
870             elif t.thing_char == '~':
871                 info += '/full'
872         if detailed:
873             protection = t.protection
874             if protection != '.':
875                 info += '/protection:%s' % protection
876             info += ')\n'
877             if hasattr(t, 'hat') or hasattr(t, 'face'):
878                 info += '----------\n'
879             if hasattr(t, 'hat'):
880                 info += '| %s |\n' % t.hat[0:6]
881                 info += '| %s |\n' % t.hat[6:12]
882                 info += '| %s |\n' % t.hat[12:18]
883             if hasattr(t, 'face'):
884                 info += '| %s |\n' % t.face[0:6]
885                 info += '| %s |\n' % t.face[6:12]
886                 info += '| %s |\n' % t.face[12:18]
887                 info += '----------\n'
888             if hasattr(t, 'design'):
889                 line_length = t.design[0].x
890                 lines = []
891                 for i in range(t.design[0].y):
892                     start = i * line_length
893                     end = (i + 1) * line_length
894                     lines += [t.design[1][start:end]]
895                 info += '-' * (line_length + 4) + '\n'
896                 for line in lines:
897                     info += '| %s |\n' % line
898                 info += '-' * (line_length + 4) + '\n'
899         else:
900             info += ')'
901         return info
902
903     def loop(self, stdscr):
904         import datetime
905
906         def safe_addstr(y, x, line):
907             if y < self.size.y - 1 or x + len(line) < self.size.x:
908                 stdscr.addstr(y, x, line, curses.color_pair(1))
909             else:  # workaround to <https://stackoverflow.com/q/7063128>
910                 cut_i = self.size.x - x - 1
911                 cut = line[:cut_i]
912                 last_char = line[cut_i]
913                 stdscr.addstr(y, self.size.x - 2, last_char, curses.color_pair(1))
914                 stdscr.insstr(y, self.size.x - 2, ' ')
915                 stdscr.addstr(y, x, cut, curses.color_pair(1))
916
917         def handle_input(msg):
918             command, args = self.parser.parse(msg)
919             command(*args)
920
921         def task_action_on(action):
922             return action_tasks[action] in self.game.tasks
923
924         def msg_into_lines_of_width(msg, width):
925             chunk = ''
926             lines = []
927             x = 0
928             for i in range(len(msg)):
929                 if x >= width or msg[i] == "\n":
930                     lines += [chunk]
931                     chunk = ''
932                     x = 0
933                     if msg[i] == "\n":
934                         x -= 1
935                 if msg[i] != "\n":
936                     chunk += msg[i]
937                 x += 1
938             lines += [chunk]
939             return lines
940
941         def reset_screen_size():
942             self.size = YX(*stdscr.getmaxyx())
943             self.size = self.size - YX(self.size.y % 4, 0)
944             self.size = self.size - YX(0, self.size.x % 4)
945             self.left_window_width = min(52, int(self.size.x / 2))
946             self.right_window_width = self.size.x - self.left_window_width
947
948         def recalc_input_lines():
949             if not self.mode.has_input_prompt:
950                 self.input_lines = []
951             else:
952                 self.input_lines = msg_into_lines_of_width(input_prompt
953                                                            + self.input_ + '█',
954                                                            self.right_window_width)
955
956         def move_explorer(direction):
957             target = self.game.map_geometry.move_yx(self.explorer, direction)
958             if target:
959                 self.info_cached = None
960                 self.explorer = target
961                 if self.tile_draw:
962                     self.send_tile_control_command()
963             else:
964                 self.flash = True
965
966         def draw_history():
967             lines = []
968             for line in self.log:
969                 lines += msg_into_lines_of_width(line, self.right_window_width)
970             lines.reverse()
971             height_header = 2
972             max_y = self.size.y - len(self.input_lines)
973             for i in range(len(lines)):
974                 if (i >= max_y - height_header):
975                     break
976                 safe_addstr(max_y - i - 1, self.left_window_width, lines[i])
977
978         def draw_info():
979             info = 'MAP VIEW: %s\n%s' % (self.map_mode, self.get_info())
980             lines = msg_into_lines_of_width(info, self.right_window_width)
981             height_header = 2
982             for i in range(len(lines)):
983                 y = height_header + i
984                 if y >= self.size.y - len(self.input_lines):
985                     break
986                 safe_addstr(y, self.left_window_width, lines[i])
987
988         def draw_input():
989             y = self.size.y - len(self.input_lines)
990             for i in range(len(self.input_lines)):
991                 safe_addstr(y, self.left_window_width, self.input_lines[i])
992                 y += 1
993
994         def draw_stats():
995             stats = 'ENERGY: %s BLADDER: %s' % (self.game.energy,
996                                                 self.game.bladder_pressure)
997             safe_addstr(0, self.left_window_width, stats)
998
999         def draw_mode():
1000             help = "hit [%s] for help" % self.keys['help']
1001             if self.mode.has_input_prompt:
1002                 help = "enter /help for help"
1003             safe_addstr(1, self.left_window_width,
1004                         'MODE: %s – %s' % (self.mode.short_desc, help))
1005
1006         def draw_map():
1007             if (not self.game.turn_complete) and len(self.map_lines) == 0:
1008                 return
1009             if self.game.turn_complete:
1010                 map_lines_split = []
1011                 for y in range(self.game.map_geometry.size.y):
1012                     start = self.game.map_geometry.size.x * y
1013                     end = start + self.game.map_geometry.size.x
1014                     if self.map_mode == 'protections':
1015                         map_lines_split += [[c + ' ' for c
1016                                              in self.game.map_control_content[start:end]]]
1017                     else:
1018                         map_lines_split += [[c + ' ' for c
1019                                              in self.game.map_content[start:end]]]
1020                 if self.map_mode == 'terrain + annotations':
1021                     for p in self.game.annotations:
1022                         map_lines_split[p.y][p.x] = 'A '
1023                 elif self.map_mode == 'terrain + things':
1024                     for p in self.game.portals.keys():
1025                         original = map_lines_split[p.y][p.x]
1026                         map_lines_split[p.y][p.x] = original[0] + 'P'
1027                     used_positions = []
1028
1029                     def draw_thing(t, used_positions):
1030                         symbol = self.game.thing_types[t.type_]
1031                         meta_char = ' '
1032                         if hasattr(t, 'thing_char'):
1033                             meta_char = t.thing_char
1034                         if t.position in used_positions:
1035                             meta_char = '+'
1036                         if hasattr(t, 'carrying') and t.carrying:
1037                             meta_char = '$'
1038                         map_lines_split[t.position.y][t.position.x] = symbol + meta_char
1039                         used_positions += [t.position]
1040
1041                     for t in [t for t in self.game.things if t.type_ != 'Player']:
1042                         draw_thing(t, used_positions)
1043                     for t in [t for t in self.game.things if t.type_ == 'Player']:
1044                         draw_thing(t, used_positions)
1045                 if self.mode.shows_info or self.mode.name == 'control_tile_draw':
1046                     map_lines_split[self.explorer.y][self.explorer.x] = '??'
1047                 elif self.map_mode != 'terrain + things':
1048                     map_lines_split[self.game.player.position.y]\
1049                         [self.game.player.position.x] = '??'
1050                 self.map_lines = []
1051                 if type(self.game.map_geometry) == MapGeometryHex:
1052                     indent = 0
1053                     for line in map_lines_split:
1054                         self.map_lines += [indent * ' ' + ''.join(line)]
1055                         indent = 0 if indent else 1
1056                 else:
1057                     for line in map_lines_split:
1058                         self.map_lines += [''.join(line)]
1059                 window_center = YX(int(self.size.y / 2),
1060                                    int(self.left_window_width / 2))
1061                 center = self.game.player.position
1062                 if self.mode.shows_info or self.mode.name == 'control_tile_draw':
1063                     center = self.explorer
1064                 center = YX(center.y, center.x * 2)
1065                 self.offset = center - window_center
1066                 if type(self.game.map_geometry) == MapGeometryHex and self.offset.y % 2:
1067                     self.offset += YX(0, 1)
1068             term_y = max(0, -self.offset.y)
1069             term_x = max(0, -self.offset.x)
1070             map_y = max(0, self.offset.y)
1071             map_x = max(0, self.offset.x)
1072             while term_y < self.size.y and map_y < len(self.map_lines):
1073                 to_draw = self.map_lines[map_y][map_x:self.left_window_width + self.offset.x]
1074                 safe_addstr(term_y, term_x, to_draw)
1075                 term_y += 1
1076                 map_y += 1
1077
1078         def draw_face_popup():
1079             t = self.game.get_thing(self.draw_face)
1080             if not t or not hasattr(t, 'face'):
1081                 self.draw_face = False
1082                 return
1083
1084             start_x = self.left_window_width - 10
1085             def draw_body_part(body_part, end_y):
1086                 safe_addstr(end_y - 3, start_x, '----------')
1087                 safe_addstr(end_y - 2, start_x, '| ' + body_part[0:6] + ' |')
1088                 safe_addstr(end_y - 1, start_x, '| ' + body_part[6:12] + ' |')
1089                 safe_addstr(end_y, start_x, '| ' + body_part[12:18] + ' |')
1090
1091             if hasattr(t, 'face'):
1092                 draw_body_part(t.face, self.size.y - 3)
1093             if hasattr(t, 'hat'):
1094                 draw_body_part(t.hat, self.size.y - 6)
1095             safe_addstr(self.size.y - 2, start_x, '----------')
1096             name = t.name[:]
1097             if len(name) > 6:
1098                 name = name[:6] + '…'
1099             safe_addstr(self.size.y - 1, start_x,
1100                         '@%s:%s' % (t.thing_char, name))
1101
1102         def draw_help():
1103             content = "%s help\n\n%s\n\n" % (self.mode.short_desc,
1104                                              self.mode.help_intro)
1105             if len(self.mode.available_actions) > 0:
1106                 content += "Available actions:\n"
1107                 for action in self.mode.available_actions:
1108                     if action in action_tasks:
1109                         if action_tasks[action] not in self.game.tasks:
1110                             continue
1111                     if action == 'move_explorer':
1112                         action = 'move'
1113                     if action == 'move':
1114                         key = ','.join(self.movement_keys)
1115                     else:
1116                         key = self.keys[action]
1117                     content += '[%s] – %s\n' % (key, action_descriptions[action])
1118                 content += '\n'
1119             content += self.mode.list_available_modes(self)
1120             for i in range(self.size.y):
1121                 safe_addstr(i,
1122                             self.left_window_width * (not self.mode.has_input_prompt),
1123                             ' ' * self.left_window_width)
1124             lines = []
1125             for line in content.split('\n'):
1126                 lines += msg_into_lines_of_width(line, self.right_window_width)
1127             for i in range(len(lines)):
1128                 if i >= self.size.y:
1129                     break
1130                 safe_addstr(i,
1131                             self.left_window_width * (not self.mode.has_input_prompt),
1132                             lines[i])
1133
1134         def draw_screen():
1135             stdscr.clear()
1136             stdscr.bkgd(' ', curses.color_pair(1))
1137             recalc_input_lines()
1138             if self.mode.has_input_prompt:
1139                 draw_input()
1140             if self.mode.shows_info:
1141                 draw_info()
1142             else:
1143                 draw_history()
1144             draw_mode()
1145             if not self.mode.is_intro:
1146                 draw_stats()
1147                 draw_map()
1148             if self.show_help:
1149                 draw_help()
1150             if self.draw_face and self.mode.name in {'chat', 'play'}:
1151                 draw_face_popup()
1152
1153         def pick_selectable(task_name):
1154             try:
1155                 i = int(self.input_)
1156                 if i < 0 or i >= len(self.selectables):
1157                     self.log_msg('? invalid index, aborted')
1158                 else:
1159                     self.send('TASK:%s %s' % (task_name, self.selectables[i]))
1160             except ValueError:
1161                 self.log_msg('? invalid index, aborted')
1162             self.input_ = ''
1163             self.switch_mode('play')
1164
1165         def enter_ascii_art(command, height, width,
1166                             with_pw=False, with_size=False):
1167             if with_size and self.ascii_draw_stage == 0:
1168                 width = len(self.input_)
1169                 if width > 36:
1170                     self.log_msg('? input too long, must be max 36; try again')
1171                     # TODO: move max width mechanism server-side
1172                     return
1173                 old_size = self.game.player.carrying.design[0]
1174                 if width != old_size.x:
1175                     # TODO: save remaining design?
1176                     self.game.player.carrying.design[1] = ''
1177                     self.game.player.carrying.design[0] = YX(old_size.y, width)
1178             elif len(self.input_) > width:
1179                 self.log_msg('? input too long, '
1180                              'must be max %s; try again' % width)
1181                 return
1182             self.log_msg('  ' + self.input_)
1183             if with_size and self.input_ in {'', ' '}\
1184                and self.ascii_draw_stage > 0:
1185                 height = self.ascii_draw_stage
1186             else:
1187                 if with_size:
1188                     height = self.ascii_draw_stage + 2
1189                 if len(self.input_) < width:
1190                     self.input_ += ' ' * (width - len(self.input_))
1191                 self.full_ascii_draw += self.input_
1192             if with_size:
1193                 old_size = self.game.player.carrying.design[0]
1194                 self.game.player.carrying.design[0] = YX(height, old_size.x)
1195             self.ascii_draw_stage += 1
1196             if self.ascii_draw_stage < height:
1197                 self.restore_input_values()
1198             else:
1199                 if with_pw and with_size:
1200                     self.send('%s_SIZE %s %s' % (command, YX(height, width),
1201                                                  quote(self.password)))
1202                 if with_pw:
1203                     self.send('%s %s %s' % (command, quote(self.full_ascii_draw),
1204                                             quote(self.password)))
1205                 else:
1206                     self.send('%s %s' % (command, quote(self.full_ascii_draw)))
1207                 self.full_ascii_draw = ""
1208                 self.ascii_draw_stage = 0
1209                 self.input_ = ""
1210                 self.switch_mode('edit')
1211
1212         action_descriptions = {
1213             'move': 'move',
1214             'flatten': 'flatten surroundings',
1215             'teleport': 'teleport',
1216             'take_thing': 'pick up thing',
1217             'drop_thing': 'drop thing',
1218             'toggle_map_mode': 'toggle map view',
1219             'toggle_tile_draw': 'toggle protection character drawing',
1220             'install': '(un-)install',
1221             'wear': '(un-)wear',
1222             'door': 'open/close',
1223             'consume': 'consume',
1224             'spin': 'spin',
1225             'dance': 'dance',
1226         }
1227
1228         action_tasks = {
1229             'flatten': 'FLATTEN_SURROUNDINGS',
1230             'take_thing': 'PICK_UP',
1231             'drop_thing': 'DROP',
1232             'door': 'DOOR',
1233             'install': 'INSTALL',
1234             'wear': 'WEAR',
1235             'move': 'MOVE',
1236             'command': 'COMMAND',
1237             'consume': 'INTOXICATE',
1238             'spin': 'SPIN',
1239             'dance': 'DANCE',
1240         }
1241
1242         curses.curs_set(False)  # hide cursor
1243         curses.start_color()
1244         self.set_default_colors()
1245         curses.init_pair(1, 1, 2)
1246         stdscr.timeout(10)
1247         reset_screen_size()
1248         self.explorer = YX(0, 0)
1249         self.input_ = ''
1250         store_widechar = False
1251         input_prompt = '> '
1252         interval = datetime.timedelta(seconds=5)
1253         last_ping = datetime.datetime.now() - interval
1254         while True:
1255             if self.disconnected and self.force_instant_connect:
1256                 self.force_instant_connect = False
1257                 self.connect()
1258             now = datetime.datetime.now()
1259             if now - last_ping > interval:
1260                 if self.disconnected:
1261                     self.connect()
1262                 else:
1263                     self.send('PING')
1264                 last_ping = now
1265             if self.flash:
1266                 curses.flash()
1267                 self.flash = False
1268             if self.do_refresh:
1269                 draw_screen()
1270                 self.do_refresh = False
1271             while True:
1272                 try:
1273                     msg = self.queue.get(block=False)
1274                     handle_input(msg)
1275                 except queue.Empty:
1276                     break
1277             try:
1278                 key = stdscr.getkey()
1279                 self.do_refresh = True
1280             except curses.error:
1281                 continue
1282             keycode = None
1283             if len(key) == 1:
1284                 keycode = ord(key)
1285                 # workaround for <https://stackoverflow.com/a/56390915>
1286                 if store_widechar:
1287                     store_widechar = False
1288                     key = bytes([195, keycode]).decode()
1289                 if keycode == 195:
1290                     store_widechar = True
1291                     continue
1292             self.show_help = False
1293             self.draw_face = False
1294             if key == 'KEY_RESIZE':
1295                 reset_screen_size()
1296             elif self.mode.has_input_prompt and key == 'KEY_BACKSPACE':
1297                 self.input_ = self.input_[:-1]
1298             elif (((not self.mode.is_intro) and keycode == 27)  # Escape
1299                   or (self.mode.has_input_prompt and key == '\n'
1300                       and self.input_ == ''\
1301                       and self.mode.name in {'chat', 'command_thing',
1302                                              'take_thing', 'drop_thing',
1303                                              'admin_enter'})):
1304                 if self.mode.name not in {'chat', 'play', 'study', 'edit'}:
1305                     self.log_msg('@ aborted')
1306                 self.switch_mode('play')
1307             elif self.mode.has_input_prompt and key == '\n' and self.input_ == '/help':
1308                 self.show_help = True
1309                 self.input_ = ""
1310                 self.restore_input_values()
1311             elif self.mode.has_input_prompt and key != '\n':  # Return key
1312                 self.input_ += key
1313                 max_length = self.right_window_width * self.size.y - len(input_prompt) - 1
1314                 if len(self.input_) > max_length:
1315                     self.input_ = self.input_[:max_length]
1316             elif key == self.keys['help'] and not self.mode.is_single_char_entry:
1317                 self.show_help = True
1318             elif self.mode.name == 'login' and key == '\n':
1319                 self.login_name = self.input_
1320                 self.send('LOGIN ' + quote(self.input_))
1321                 self.input_ = ""
1322             elif self.mode.name == 'enter_face' and key == '\n':
1323                 enter_ascii_art('PLAYER_FACE', 3, 6)
1324             elif self.mode.name == 'enter_design' and key == '\n':
1325                 if self.game.player.carrying.type_ == 'Hat':
1326                     enter_ascii_art('THING_DESIGN',
1327                                     self.game.player.carrying.design[0].y,
1328                                     self.game.player.carrying.design[0].x, True)
1329                 else:
1330                     enter_ascii_art('THING_DESIGN',
1331                                     self.game.player.carrying.design[0].y,
1332                                     self.game.player.carrying.design[0].x,
1333                                     True, True)
1334             elif self.mode.name == 'take_thing' and key == '\n':
1335                 pick_selectable('PICK_UP')
1336             elif self.mode.name == 'drop_thing' and key == '\n':
1337                 pick_selectable('DROP')
1338             elif self.mode.name == 'command_thing' and key == '\n':
1339                 self.send('TASK:COMMAND ' + quote(self.input_))
1340                 self.input_ = ""
1341             elif self.mode.name == 'control_pw_pw' and key == '\n':
1342                 if self.input_ == '':
1343                     self.log_msg('@ aborted')
1344                 else:
1345                     self.send('SET_MAP_CONTROL_PASSWORD ' + quote(self.tile_control_char) + ' ' + quote(self.input_))
1346                     self.log_msg('@ sent new password for protection character "%s"' % self.tile_control_char)
1347                 self.switch_mode('admin')
1348             elif self.mode.name == 'password' and key == '\n':
1349                 if self.input_ == '':
1350                     self.input_ = ' '
1351                 self.password = self.input_
1352                 self.switch_mode('edit')
1353             elif self.mode.name == 'admin_enter' and key == '\n':
1354                 self.send('BECOME_ADMIN ' + quote(self.input_))
1355                 self.switch_mode('play')
1356             elif self.mode.name == 'control_pw_type' and key == '\n':
1357                 if len(self.input_) != 1:
1358                     self.log_msg('@ entered non-single-char, therefore aborted')
1359                     self.switch_mode('admin')
1360                 else:
1361                     self.tile_control_char = self.input_
1362                     self.switch_mode('control_pw_pw')
1363             elif self.mode.name == 'admin_thing_protect' and key == '\n':
1364                 if len(self.input_) != 1:
1365                     self.log_msg('@ entered non-single-char, therefore aborted')
1366                 else:
1367                     self.send('THING_PROTECTION %s' % (quote(self.input_)))
1368                     self.log_msg('@ sent new protection character for thing')
1369                 self.switch_mode('admin')
1370             elif self.mode.name == 'control_tile_type' and key == '\n':
1371                 if len(self.input_) != 1:
1372                     self.log_msg('@ entered non-single-char, therefore aborted')
1373                     self.switch_mode('admin')
1374                 else:
1375                     self.tile_control_char = self.input_
1376                     self.switch_mode('control_tile_draw')
1377             elif self.mode.name == 'chat' and key == '\n':
1378                 if self.input_ == '':
1379                     continue
1380                 if self.input_[0] == '/':
1381                     if self.input_.startswith('/nick'):
1382                         tokens = self.input_.split(maxsplit=1)
1383                         if len(tokens) == 2:
1384                             self.send('NICK ' + quote(tokens[1]))
1385                         else:
1386                             self.log_msg('? need login name')
1387                     else:
1388                         self.log_msg('? unknown command')
1389                 else:
1390                     self.send('ALL ' + quote(self.input_))
1391                 self.input_ = ""
1392             elif self.mode.name == 'name_thing' and key == '\n':
1393                 if self.input_ == '':
1394                     self.input_ = ' '
1395                 self.send('THING_NAME %s %s' % (quote(self.input_),
1396                                                 quote(self.password)))
1397                 self.switch_mode('edit')
1398             elif self.mode.name == 'annotate' and key == '\n':
1399                 if self.input_ == '':
1400                     self.input_ = ' '
1401                 self.send('ANNOTATE %s %s %s' % (self.explorer, quote(self.input_),
1402                                                  quote(self.password)))
1403                 self.switch_mode('edit')
1404             elif self.mode.name == 'portal' and key == '\n':
1405                 if self.input_ == '':
1406                     self.input_ = ' '
1407                 self.send('PORTAL %s %s %s' % (self.explorer, quote(self.input_),
1408                                                quote(self.password)))
1409                 self.switch_mode('edit')
1410             elif self.mode.name == 'study':
1411                 if self.mode.mode_switch_on_key(self, key):
1412                     continue
1413                 elif key == self.keys['toggle_map_mode']:
1414                     self.toggle_map_mode()
1415                 elif key in self.movement_keys:
1416                     move_explorer(self.movement_keys[key])
1417             elif self.mode.name == 'play':
1418                 if self.mode.mode_switch_on_key(self, key):
1419                     continue
1420                 elif key == self.keys['door'] and task_action_on('door'):
1421                     self.send('TASK:DOOR')
1422                 elif key == self.keys['consume'] and task_action_on('consume'):
1423                     self.send('TASK:INTOXICATE')
1424                 elif key == self.keys['wear'] and task_action_on('wear'):
1425                     self.send('TASK:WEAR')
1426                 elif key == self.keys['spin'] and task_action_on('spin'):
1427                     self.send('TASK:SPIN')
1428                 elif key == self.keys['dance'] and task_action_on('dance'):
1429                     self.send('TASK:DANCE')
1430                 elif key == self.keys['teleport']:
1431                     if self.game.player.position in self.game.portals:
1432                         self.host = self.game.portals[self.game.player.position]
1433                         self.reconnect()
1434                     else:
1435                         self.flash = True
1436                         self.log_msg('? not standing on portal')
1437                 elif key in self.movement_keys and task_action_on('move'):
1438                     self.send('TASK:MOVE ' + self.movement_keys[key])
1439             elif self.mode.name == 'write':
1440                 self.send('TASK:WRITE %s %s' % (key, quote(self.password)))
1441                 self.switch_mode('edit')
1442             elif self.mode.name == 'control_tile_draw':
1443                 if self.mode.mode_switch_on_key(self, key):
1444                     continue
1445                 elif key in self.movement_keys:
1446                     move_explorer(self.movement_keys[key])
1447                 elif key == self.keys['toggle_tile_draw']:
1448                     self.tile_draw = False if self.tile_draw else True
1449             elif self.mode.name == 'admin':
1450                 if self.mode.mode_switch_on_key(self, key):
1451                     continue
1452                 elif key == self.keys['toggle_map_mode']:
1453                     self.toggle_map_mode()
1454                 elif key in self.movement_keys and task_action_on('move'):
1455                     self.send('TASK:MOVE ' + self.movement_keys[key])
1456             elif self.mode.name == 'edit':
1457                 if self.mode.mode_switch_on_key(self, key):
1458                     continue
1459                 elif key == self.keys['flatten'] and task_action_on('flatten'):
1460                     self.send('TASK:FLATTEN_SURROUNDINGS ' + quote(self.password))
1461                 elif key == self.keys['install'] and task_action_on('install'):
1462                     self.send('TASK:INSTALL %s' % quote(self.password))
1463                 elif key == self.keys['toggle_map_mode']:
1464                     self.toggle_map_mode()
1465                 elif key in self.movement_keys and task_action_on('move'):
1466                     self.send('TASK:MOVE ' + self.movement_keys[key])
1467
1468 if len(sys.argv) != 2:
1469     raise ArgError('wrong number of arguments, need game host')
1470 host = sys.argv[1]
1471 TUI(host)