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