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