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