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