home · contact · privacy
Refactor clients.
[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         'long': 'This mode allows you to interact with the map in various ways.'
18     },
19     'study': {
20         'short': 'study',
21         '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.'},
22     'edit': {
23         'short': 'world edit',
24         '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.'
25     },
26     'name_thing': {
27         'short': 'name thing',
28         'long': 'Give name to/change name of thing here.'
29     },
30     'admin_thing_protect': {
31         'short': 'change thing protection',
32         'long': 'Change protection character for thing here.'
33     },
34     'write': {
35         'short': 'change terrain',
36         '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.'
37     },
38     'control_pw_type': {
39         'short': 'change protection character password',
40         '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.'
41     },
42     'control_pw_pw': {
43         'short': 'change protection character password',
44         '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.'
45     },
46     'control_tile_type': {
47         'short': 'change tiles protection',
48         '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.'
49     },
50     'control_tile_draw': {
51         'short': 'change tiles protection',
52         '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.'
53     },
54     'annotate': {
55         'short': 'annotate tile',
56         '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.'
57     },
58     'portal': {
59         'short': 'edit portal',
60         '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.'
61     },
62     'chat': {
63         'short': 'chat',
64         '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:'
65     },
66     'login': {
67         'short': 'login',
68         'long': 'Enter your player name.'
69     },
70     'waiting_for_server': {
71         'short': 'waiting for server response',
72         'long': 'Waiting for a server response.'
73     },
74     'post_login_wait': {
75         'short': 'waiting for server response',
76         'long': 'Waiting for a server response.'
77     },
78     'password': {
79         'short': 'set world edit password',
80         '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.'
81     },
82     'admin_enter': {
83         'short': 'become admin',
84         'long': 'This mode allows you to become admin if you know an admin password.'
85     },
86     'admin': {
87         'short': 'admin',
88         'long': 'This mode allows you access to actions limited to administrators.'
89     }
90 }
91
92 from ws4py.client import WebSocketBaseClient
93 class WebSocketClient(WebSocketBaseClient):
94
95     def __init__(self, recv_handler, *args, **kwargs):
96         super().__init__(*args, **kwargs)
97         self.recv_handler = recv_handler
98         self.connect()
99
100     def received_message(self, message):
101         if message.is_text:
102             message = str(message)
103             self.recv_handler(message)
104
105     @property
106     def plom_closed(self):
107         return self.client_terminated
108
109 from plomrogue.io_tcp import PlomSocket
110 class PlomSocketClient(PlomSocket):
111
112     def __init__(self, recv_handler, url):
113         import socket
114         self.recv_handler = recv_handler
115         host, port = url.split(':')
116         super().__init__(socket.create_connection((host, port)))
117
118     def close(self):
119         self.socket.close()
120
121     def run(self):
122         import ssl
123         try:
124             for msg in self.recv():
125                 if msg == 'NEED_SSL':
126                     self.socket = ssl.wrap_socket(self.socket)
127                     continue
128                 self.recv_handler(msg)
129         except BrokenSocketConnection:
130             pass  # we assume socket will be known as dead by now
131
132 def cmd_TURN(game, n):
133     game.info_db = {}
134     game.info_hints = []
135     game.turn = n
136     game.things = []
137     game.portals = {}
138     game.turn_complete = False
139 cmd_TURN.argtypes = 'int:nonneg'
140
141 def cmd_LOGIN_OK(game):
142     game.tui.switch_mode('post_login_wait')
143     game.tui.send('GET_GAMESTATE')
144     game.tui.log_msg('@ welcome')
145 cmd_LOGIN_OK.argtypes = ''
146
147 def cmd_ADMIN_OK(game):
148     game.tui.is_admin = True
149     game.tui.log_msg('@ you now have admin rights')
150     game.tui.switch_mode('admin')
151     game.tui.do_refresh = True
152 cmd_ADMIN_OK.argtypes = ''
153
154 def cmd_CHAT(game, msg):
155     game.tui.log_msg('# ' + msg)
156     game.tui.do_refresh = True
157 cmd_CHAT.argtypes = 'string'
158
159 def cmd_PLAYER_ID(game, player_id):
160     game.player_id = player_id
161 cmd_PLAYER_ID.argtypes = 'int:nonneg'
162
163 def cmd_THING(game, yx, thing_type, protection, thing_id):
164     t = game.get_thing(thing_id)
165     if not t:
166         t = ThingBase(game, thing_id)
167         game.things += [t]
168     t.position = yx
169     t.type_ = thing_type
170     t.protection = protection
171 cmd_THING.argtypes = 'yx_tuple:nonneg string:thing_type char int:nonneg'
172
173 def cmd_THING_NAME(game, thing_id, name):
174     t = game.get_thing(thing_id)
175     if t:
176         t.name = name
177 cmd_THING_NAME.argtypes = 'int:nonneg string'
178
179 def cmd_THING_CHAR(game, thing_id, c):
180     t = game.get_thing(thing_id)
181     if t:
182         t.player_char = c
183 cmd_THING_CHAR.argtypes = 'int:nonneg char'
184
185 def cmd_MAP(game, geometry, size, content):
186     map_geometry_class = globals()['MapGeometry' + geometry]
187     game.map_geometry = map_geometry_class(size)
188     game.map_content = content
189     if type(game.map_geometry) == MapGeometrySquare:
190         game.tui.movement_keys = {
191             game.tui.keys['square_move_up']: 'UP',
192             game.tui.keys['square_move_left']: 'LEFT',
193             game.tui.keys['square_move_down']: 'DOWN',
194             game.tui.keys['square_move_right']: 'RIGHT',
195         }
196     elif type(game.map_geometry) == MapGeometryHex:
197         game.tui.movement_keys = {
198             game.tui.keys['hex_move_upleft']: 'UPLEFT',
199             game.tui.keys['hex_move_upright']: 'UPRIGHT',
200             game.tui.keys['hex_move_right']: 'RIGHT',
201             game.tui.keys['hex_move_downright']: 'DOWNRIGHT',
202             game.tui.keys['hex_move_downleft']: 'DOWNLEFT',
203             game.tui.keys['hex_move_left']: 'LEFT',
204         }
205 cmd_MAP.argtypes = 'string:map_geometry yx_tuple:pos string'
206
207 def cmd_FOV(game, content):
208     game.fov = content
209 cmd_FOV.argtypes = 'string'
210
211 def cmd_MAP_CONTROL(game, content):
212     game.map_control_content = content
213 cmd_MAP_CONTROL.argtypes = 'string'
214
215 def cmd_GAME_STATE_COMPLETE(game):
216     if game.tui.mode.name == 'post_login_wait':
217         game.tui.switch_mode('play')
218     if game.tui.mode.shows_info:
219         game.tui.query_info()
220     game.turn_complete = True
221     game.tui.do_refresh = True
222 cmd_GAME_STATE_COMPLETE.argtypes = ''
223
224 def cmd_PORTAL(game, position, msg):
225     game.portals[position] = msg
226 cmd_PORTAL.argtypes = 'yx_tuple:nonneg string'
227
228 def cmd_PLAY_ERROR(game, msg):
229     game.tui.log_msg('? ' + msg)
230     game.tui.flash = True
231     game.tui.do_refresh = True
232 cmd_PLAY_ERROR.argtypes = 'string'
233
234 def cmd_GAME_ERROR(game, msg):
235     game.tui.log_msg('? game error: ' + msg)
236     game.tui.do_refresh = True
237 cmd_GAME_ERROR.argtypes = 'string'
238
239 def cmd_ARGUMENT_ERROR(game, msg):
240     game.tui.log_msg('? syntax error: ' + msg)
241     game.tui.do_refresh = True
242 cmd_ARGUMENT_ERROR.argtypes = 'string'
243
244 def cmd_ANNOTATION_HINT(game, position):
245     game.info_hints += [position]
246 cmd_ANNOTATION_HINT.argtypes = 'yx_tuple:nonneg'
247
248 def cmd_ANNOTATION(game, position, msg):
249     game.info_db[position] = msg
250     if game.tui.mode.shows_info:
251         game.tui.do_refresh = True
252 cmd_ANNOTATION.argtypes = 'yx_tuple:nonneg string'
253
254 def cmd_TASKS(game, tasks_comma_separated):
255     game.tasks = tasks_comma_separated.split(',')
256     game.tui.mode_write.legal = 'WRITE' in game.tasks
257 cmd_TASKS.argtypes = 'string'
258
259 def cmd_THING_TYPE(game, thing_type, symbol_hint):
260     game.thing_types[thing_type] = symbol_hint
261 cmd_THING_TYPE.argtypes = 'string char'
262
263 def cmd_TERRAIN(game, terrain_char, terrain_desc):
264     game.terrains[terrain_char] = terrain_desc
265 cmd_TERRAIN.argtypes = 'char string'
266
267 def cmd_PONG(game):
268     pass
269 cmd_PONG.argtypes = ''
270
271 class Game(GameBase):
272     turn_complete = False
273     tasks = {}
274     thing_types = {}
275
276     def __init__(self, *args, **kwargs):
277         super().__init__(*args, **kwargs)
278         self.register_command(cmd_LOGIN_OK)
279         self.register_command(cmd_ADMIN_OK)
280         self.register_command(cmd_PONG)
281         self.register_command(cmd_CHAT)
282         self.register_command(cmd_PLAYER_ID)
283         self.register_command(cmd_TURN)
284         self.register_command(cmd_THING)
285         self.register_command(cmd_THING_TYPE)
286         self.register_command(cmd_THING_NAME)
287         self.register_command(cmd_THING_CHAR)
288         self.register_command(cmd_TERRAIN)
289         self.register_command(cmd_MAP)
290         self.register_command(cmd_MAP_CONTROL)
291         self.register_command(cmd_PORTAL)
292         self.register_command(cmd_ANNOTATION)
293         self.register_command(cmd_ANNOTATION_HINT)
294         self.register_command(cmd_GAME_STATE_COMPLETE)
295         self.register_command(cmd_ARGUMENT_ERROR)
296         self.register_command(cmd_GAME_ERROR)
297         self.register_command(cmd_PLAY_ERROR)
298         self.register_command(cmd_TASKS)
299         self.register_command(cmd_FOV)
300         self.map_content = ''
301         self.player_id = -1
302         self.info_db = {}
303         self.info_hints = []
304         self.portals = {}
305         self.terrains = {}
306
307     def get_string_options(self, string_option_type):
308         if string_option_type == 'map_geometry':
309             return ['Hex', 'Square']
310         elif string_option_type == 'thing_type':
311             return self.thing_types.keys()
312         return None
313
314     def get_command(self, command_name):
315         from functools import partial
316         f = partial(self.commands[command_name], self)
317         f.argtypes = self.commands[command_name].argtypes
318         return f
319
320 class Mode:
321
322     def __init__(self, name, has_input_prompt=False, shows_info=False,
323                  is_intro=False, is_single_char_entry=False):
324         self.name = name
325         self.short_desc = mode_helps[name]['short']
326         self.available_modes = []
327         self.available_actions = []
328         self.has_input_prompt = has_input_prompt
329         self.shows_info = shows_info
330         self.is_intro = is_intro
331         self.help_intro = mode_helps[name]['long']
332         self.is_single_char_entry = is_single_char_entry
333         self.legal = True
334
335     def iter_available_modes(self, tui):
336         for mode_name in self.available_modes:
337             mode = getattr(tui, 'mode_' + mode_name)
338             if not mode.legal:
339                 continue
340             key = tui.keys['switch_to_' + mode.name]
341             yield mode, key
342
343     def list_available_modes(self, tui):
344         msg = ''
345         if len(self.available_modes) > 0:
346             msg = 'Other modes available from here:\n'
347             for mode, key in self.iter_available_modes(tui):
348                 msg += '[%s] – %s\n' % (key, mode.short_desc)
349         return msg
350
351     def mode_switch_on_key(self, tui, key_pressed):
352         for mode, key in self.iter_available_modes(tui):
353             if key_pressed == key:
354                 tui.switch_mode(mode.name)
355                 return True
356         return False
357
358 class TUI:
359     mode_admin_enter = Mode('admin_enter', has_input_prompt=True)
360     mode_admin = Mode('admin')
361     mode_play = Mode('play')
362     mode_study = Mode('study', shows_info=True)
363     mode_write = Mode('write', is_single_char_entry=True)
364     mode_edit = Mode('edit')
365     mode_control_pw_type = Mode('control_pw_type', has_input_prompt=True)
366     mode_control_pw_pw = Mode('control_pw_pw', has_input_prompt=True)
367     mode_control_tile_type = Mode('control_tile_type', has_input_prompt=True)
368     mode_control_tile_draw = Mode('control_tile_draw')
369     mode_admin_thing_protect = Mode('admin_thing_protect', has_input_prompt=True)
370     mode_annotate = Mode('annotate', has_input_prompt=True, shows_info=True)
371     mode_portal = Mode('portal', has_input_prompt=True, shows_info=True)
372     mode_chat = Mode('chat', has_input_prompt=True)
373     mode_waiting_for_server = Mode('waiting_for_server', is_intro=True)
374     mode_login = Mode('login', has_input_prompt=True, is_intro=True)
375     mode_post_login_wait = Mode('post_login_wait', is_intro=True)
376     mode_password = Mode('password', has_input_prompt=True)
377     mode_name_thing = Mode('name_thing', has_input_prompt=True, shows_info=True)
378     is_admin = False
379     tile_draw = False
380
381     def __init__(self, host):
382         import os
383         import json
384         self.mode_play.available_modes = ["chat", "study", "edit", "admin_enter"]
385         self.mode_play.available_actions = ["move", "take_thing", "drop_thing",
386                                             "teleport"]
387         self.mode_study.available_modes = ["chat", "play", "admin_enter", "edit"]
388         self.mode_study.available_actions = ["toggle_map_mode", "move_explorer"]
389         self.mode_admin.available_modes = ["admin_thing_protect", "control_pw_type",
390                                            "control_tile_type", "chat",
391                                            "study", "play", "edit"]
392         self.mode_admin.available_actions = ["move"]
393         self.mode_control_tile_draw.available_modes = ["admin_enter"]
394         self.mode_control_tile_draw.available_actions = ["move_explorer",
395                                                          "toggle_tile_draw"]
396         self.mode_edit.available_modes = ["write", "annotate", "portal", "name_thing",
397                                           "password", "chat", "study", "play",
398                                           "admin_enter"]
399         self.mode_edit.available_actions = ["move", "flatten", "toggle_map_mode"]
400         self.mode = None
401         self.host = host
402         self.game = Game()
403         self.game.tui = self
404         self.parser = Parser(self.game)
405         self.log = []
406         self.do_refresh = True
407         self.queue = queue.Queue()
408         self.login_name = None
409         self.map_mode = 'terrain + things'
410         self.password = 'foo'
411         self.switch_mode('waiting_for_server')
412         self.keys = {
413             'switch_to_chat': 't',
414             'switch_to_play': 'p',
415             'switch_to_password': 'P',
416             'switch_to_annotate': 'M',
417             'switch_to_portal': 'T',
418             'switch_to_study': '?',
419             'switch_to_edit': 'E',
420             'switch_to_write': 'm',
421             'switch_to_name_thing': 'N',
422             'switch_to_admin_enter': 'A',
423             'switch_to_control_pw_type': 'C',
424             'switch_to_control_tile_type': 'Q',
425             'switch_to_admin_thing_protect': 'T',
426             'flatten': 'F',
427             'take_thing': 'z',
428             'drop_thing': 'u',
429             'teleport': 'p',
430             'help': 'h',
431             'toggle_map_mode': 'L',
432             'toggle_tile_draw': 'm',
433             'hex_move_upleft': 'w',
434             'hex_move_upright': 'e',
435             'hex_move_right': 'd',
436             'hex_move_downright': 'x',
437             'hex_move_downleft': 'y',
438             'hex_move_left': 'a',
439             'square_move_up': 'w',
440             'square_move_left': 'a',
441             'square_move_down': 's',
442             'square_move_right': 'd',
443         }
444         if os.path.isfile('config.json'):
445             with open('config.json', 'r') as f:
446                 keys_conf = json.loads(f.read())
447             for k in keys_conf:
448                 self.keys[k] = keys_conf[k]
449         self.show_help = False
450         self.disconnected = True
451         self.force_instant_connect = True
452         self.input_lines = []
453         self.fov = ''
454         self.flash = False
455         curses.wrapper(self.loop)
456
457     def connect(self):
458
459         def handle_recv(msg):
460             if msg == 'BYE':
461                 self.socket.close()
462             else:
463                 self.queue.put(msg)
464
465         self.log_msg('@ attempting connect')
466         socket_client_class = PlomSocketClient
467         if self.host.startswith('ws://') or self.host.startswith('wss://'):
468             socket_client_class = WebSocketClient
469         try:
470             self.socket = socket_client_class(handle_recv, self.host)
471             self.socket_thread = threading.Thread(target=self.socket.run)
472             self.socket_thread.start()
473             self.disconnected = False
474             self.game.thing_types = {}
475             self.game.terrains = {}
476             time.sleep(0.1)  # give potential SSL negotation some time …
477             self.socket.send('TASKS')
478             self.socket.send('TERRAINS')
479             self.socket.send('THING_TYPES')
480             self.switch_mode('login')
481         except ConnectionRefusedError:
482             self.log_msg('@ server connect failure')
483             self.disconnected = True
484             self.switch_mode('waiting_for_server')
485         self.do_refresh = True
486
487     def reconnect(self):
488         self.log_msg('@ attempting reconnect')
489         self.send('QUIT')
490         # necessitated by some strange SSL race conditions with ws4py
491         time.sleep(0.1)  # FIXME find out why exactly necessary
492         self.switch_mode('waiting_for_server')
493         self.connect()
494
495     def send(self, msg):
496         try:
497             if hasattr(self.socket, 'plom_closed') and self.socket.plom_closed:
498                 raise BrokenSocketConnection
499             self.socket.send(msg)
500         except (BrokenPipeError, BrokenSocketConnection):
501             self.log_msg('@ server disconnected :(')
502             self.disconnected = True
503             self.force_instant_connect = True
504             self.do_refresh = True
505
506     def log_msg(self, msg):
507         self.log += [msg]
508         if len(self.log) > 100:
509             self.log = self.log[-100:]
510
511     def query_info(self):
512         self.send('GET_ANNOTATION ' + str(self.explorer))
513
514     def restore_input_values(self):
515         if self.mode.name == 'annotate' and self.explorer in self.game.info_db:
516             info = self.game.info_db[self.explorer]
517             if info != '(none)':
518                 self.input_ = info
519         elif self.mode.name == 'portal' and self.explorer in self.game.portals:
520             self.input_ = self.game.portals[self.explorer]
521         elif self.mode.name == 'password':
522             self.input_ = self.password
523         elif self.mode.name == 'name_thing':
524             if hasattr(self.thing_selected, 'name'):
525                 self.input_ = self.thing_selected.name
526         elif self.mode.name == 'admin_thing_protect':
527             if hasattr(self.thing_selected, 'protection'):
528                 self.input_ = self.thing_selected.protection
529
530     def send_tile_control_command(self):
531         self.send('SET_TILE_CONTROL %s %s' %
532                   (self.explorer, quote(self.tile_control_char)))
533
534     def toggle_map_mode(self):
535         if self.map_mode == 'terrain only':
536             self.map_mode = 'terrain + annotations'
537         elif self.map_mode == 'terrain + annotations':
538             self.map_mode = 'terrain + things'
539         elif self.map_mode == 'terrain + things':
540             self.map_mode = 'protections'
541         elif self.map_mode == 'protections':
542             self.map_mode = 'terrain only'
543
544     def switch_mode(self, mode_name):
545         self.tile_draw = False
546         if mode_name == 'admin_enter' and self.is_admin:
547             mode_name = 'admin'
548         elif mode_name in {'name_thing', 'admin_thing_protect'}:
549             player = self.game.get_thing(self.game.player_id)
550             thing = None
551             for t in [t for t in self.game.things if t.position == player.position
552                       and t.id_ != player.id_]:
553                 thing = t
554                 break
555             if not thing:
556                 self.flash = True
557                 self.log_msg('? not standing over thing')
558                 return
559             else:
560                 self.thing_selected = thing
561         self.mode = getattr(self, 'mode_' + mode_name)
562         if self.mode.name == 'control_tile_draw':
563             self.log_msg('@ finished tile protection drawing.')
564         if self.mode.name in {'control_tile_draw', 'control_tile_type',
565                               'control_pw_type'}:
566             self.map_mode = 'protections'
567         elif self.mode.name != 'edit':
568             self.map_mode = 'terrain + things'
569         if self.mode.shows_info or self.mode.name == 'control_tile_draw':
570             player = self.game.get_thing(self.game.player_id)
571             self.explorer = YX(player.position.y, player.position.x)
572             if self.mode.shows_info:
573                 self.query_info()
574         if self.mode.is_single_char_entry:
575             self.show_help = True
576         if self.mode.name == 'waiting_for_server':
577             self.log_msg('@ waiting for server …')
578         elif self.mode.name == 'login':
579             if self.login_name:
580                 self.send('LOGIN ' + quote(self.login_name))
581             else:
582                 self.log_msg('@ enter username')
583         elif self.mode.name == 'admin_enter':
584             self.log_msg('@ enter admin password:')
585         elif self.mode.name == 'control_pw_type':
586             self.log_msg('@ enter protection character for which you want to change the password:')
587         elif self.mode.name == 'control_tile_type':
588             self.log_msg('@ enter protection character which you want to draw:')
589         elif self.mode.name == 'admin_thing_protect':
590             self.log_msg('@ enter thing protection character:')
591         elif self.mode.name == 'control_pw_pw':
592             self.log_msg('@ enter protection password for "%s":' % self.tile_control_char)
593         elif self.mode.name == 'control_tile_draw':
594             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']))
595         self.input_ = ""
596         self.restore_input_values()
597
598     def loop(self, stdscr):
599         import datetime
600
601         def safe_addstr(y, x, line):
602             if y < self.size.y - 1 or x + len(line) < self.size.x:
603                 stdscr.addstr(y, x, line)
604             else:  # workaround to <https://stackoverflow.com/q/7063128>
605                 cut_i = self.size.x - x - 1
606                 cut = line[:cut_i]
607                 last_char = line[cut_i]
608                 stdscr.addstr(y, self.size.x - 2, last_char)
609                 stdscr.insstr(y, self.size.x - 2, ' ')
610                 stdscr.addstr(y, x, cut)
611
612         def handle_input(msg):
613             command, args = self.parser.parse(msg)
614             command(*args)
615
616         def task_action_on(action):
617             return action_tasks[action] in self.game.tasks
618
619         def msg_into_lines_of_width(msg, width):
620             chunk = ''
621             lines = []
622             x = 0
623             for i in range(len(msg)):
624                 if x >= width or msg[i] == "\n":
625                     lines += [chunk]
626                     chunk = ''
627                     x = 0
628                     if msg[i] == "\n":
629                         x -= 1
630                 if msg[i] != "\n":
631                     chunk += msg[i]
632                 x += 1
633             lines += [chunk]
634             return lines
635
636         def reset_screen_size():
637             self.size = YX(*stdscr.getmaxyx())
638             self.size = self.size - YX(self.size.y % 4, 0)
639             self.size = self.size - YX(0, self.size.x % 4)
640             self.window_width = int(self.size.x / 2)
641
642         def recalc_input_lines():
643             if not self.mode.has_input_prompt:
644                 self.input_lines = []
645             else:
646                 self.input_lines = msg_into_lines_of_width(input_prompt + self.input_,
647                                                            self.window_width)
648
649         def move_explorer(direction):
650             target = self.game.map_geometry.move_yx(self.explorer, direction)
651             if target:
652                 self.explorer = target
653                 if self.mode.shows_info:
654                     self.query_info()
655                 if self.tile_draw:
656                     self.send_tile_control_command()
657             else:
658                 self.flash = True
659
660         def draw_history():
661             lines = []
662             for line in self.log:
663                 lines += msg_into_lines_of_width(line, self.window_width)
664             lines.reverse()
665             height_header = 2
666             max_y = self.size.y - len(self.input_lines)
667             for i in range(len(lines)):
668                 if (i >= max_y - height_header):
669                     break
670                 safe_addstr(max_y - i - 1, self.window_width, lines[i])
671
672         def draw_info():
673             if not self.game.turn_complete:
674                 return
675             pos_i = self.explorer.y * self.game.map_geometry.size.x + self.explorer.x
676             info = 'MAP VIEW: %s\n' % self.map_mode
677             if self.game.fov[pos_i] != '.':
678                 info += 'outside field of view'
679             else:
680                 terrain_char = self.game.map_content[pos_i]
681                 terrain_desc = '?'
682                 if terrain_char in self.game.terrains:
683                     terrain_desc = self.game.terrains[terrain_char]
684                 info += 'TERRAIN: "%s" / %s\n' % (terrain_char, terrain_desc)
685                 protection = self.game.map_control_content[pos_i]
686                 if protection == '.':
687                     protection = 'unprotected'
688                 info += 'PROTECTION: %s\n' % protection
689                 for t in self.game.things:
690                     if t.position == self.explorer:
691                         protection = t.protection
692                         if protection == '.':
693                             protection = 'unprotected'
694                         info += 'THING: %s / protection: %s / %s' %\
695                             (t.type_, protection, self.game.thing_types[t.type_])
696                         if hasattr(t, 'player_char'):
697                             info += t.player_char
698                         if hasattr(t, 'name'):
699                             info += ' (%s)' % t.name
700                         info += '\n'
701                 if self.explorer in self.game.portals:
702                     info += 'PORTAL: ' + self.game.portals[self.explorer] + '\n'
703                 else:
704                     info += 'PORTAL: (none)\n'
705                 if self.explorer in self.game.info_db:
706                     info += 'ANNOTATION: ' + self.game.info_db[self.explorer]
707                 else:
708                     info += 'ANNOTATION: waiting …'
709             lines = msg_into_lines_of_width(info, self.window_width)
710             height_header = 2
711             for i in range(len(lines)):
712                 y = height_header + i
713                 if y >= self.size.y - len(self.input_lines):
714                     break
715                 safe_addstr(y, self.window_width, lines[i])
716
717         def draw_input():
718             y = self.size.y - len(self.input_lines)
719             for i in range(len(self.input_lines)):
720                 safe_addstr(y, self.window_width, self.input_lines[i])
721                 y += 1
722
723         def draw_turn():
724             if not self.game.turn_complete:
725                 return
726             safe_addstr(0, self.window_width, 'TURN: ' + str(self.game.turn))
727
728         def draw_mode():
729             help = "hit [%s] for help" % self.keys['help']
730             if self.mode.has_input_prompt:
731                 help = "enter /help for help"
732             safe_addstr(1, self.window_width,
733                         'MODE: %s – %s' % (self.mode.short_desc, help))
734
735         def draw_map():
736             if not self.game.turn_complete:
737                 return
738             map_lines_split = []
739             for y in range(self.game.map_geometry.size.y):
740                 start = self.game.map_geometry.size.x * y
741                 end = start + self.game.map_geometry.size.x
742                 if self.map_mode == 'protections':
743                     map_lines_split += [[c + ' ' for c
744                                          in self.game.map_control_content[start:end]]]
745                 else:
746                     map_lines_split += [[c + ' ' for c
747                                          in self.game.map_content[start:end]]]
748             if self.map_mode == 'terrain + annotations':
749                 for p in self.game.info_hints:
750                     map_lines_split[p.y][p.x] = 'A '
751             elif self.map_mode == 'terrain + things':
752                 for p in self.game.portals.keys():
753                     original = map_lines_split[p.y][p.x]
754                     map_lines_split[p.y][p.x] = original[0] + 'P'
755                 used_positions = []
756                 for t in self.game.things:
757                     symbol = self.game.thing_types[t.type_]
758                     meta_char = ' '
759                     if hasattr(t, 'player_char'):
760                         meta_char = t.player_char
761                     if t.position in used_positions:
762                         meta_char = '+'
763                     map_lines_split[t.position.y][t.position.x] = symbol + meta_char
764                     used_positions += [t.position]
765             player = self.game.get_thing(self.game.player_id)
766             if self.mode.shows_info or self.mode.name == 'control_tile_draw':
767                 map_lines_split[self.explorer.y][self.explorer.x] = '??'
768             elif self.map_mode != 'terrain + things':
769                 map_lines_split[player.position.y][player.position.x] = '??'
770             map_lines = []
771             if type(self.game.map_geometry) == MapGeometryHex:
772                 indent = 0
773                 for line in map_lines_split:
774                     map_lines += [indent * ' ' + ''.join(line)]
775                     indent = 0 if indent else 1
776             else:
777                 for line in map_lines_split:
778                     map_lines += [''.join(line)]
779             window_center = YX(int(self.size.y / 2),
780                                int(self.window_width / 2))
781             center = player.position
782             if self.mode.shows_info or self.mode.name == 'control_tile_draw':
783                 center = self.explorer
784             center = YX(center.y, center.x * 2)
785             offset = center - window_center
786             if type(self.game.map_geometry) == MapGeometryHex and offset.y % 2:
787                 offset += YX(0, 1)
788             term_y = max(0, -offset.y)
789             term_x = max(0, -offset.x)
790             map_y = max(0, offset.y)
791             map_x = max(0, offset.x)
792             while (term_y < self.size.y and map_y < self.game.map_geometry.size.y):
793                 to_draw = map_lines[map_y][map_x:self.window_width + offset.x]
794                 safe_addstr(term_y, term_x, to_draw)
795                 term_y += 1
796                 map_y += 1
797
798         def draw_help():
799             content = "%s help\n\n%s\n\n" % (self.mode.short_desc,
800                                              self.mode.help_intro)
801             if len(self.mode.available_actions) > 0:
802                 content += "Available actions:\n"
803                 for action in self.mode.available_actions:
804                     if action in action_tasks:
805                         if action_tasks[action] not in self.game.tasks:
806                             continue
807                     if action == 'move_explorer':
808                         action = 'move'
809                     if action == 'move':
810                         key = ','.join(self.movement_keys)
811                     else:
812                         key = self.keys[action]
813                     content += '[%s] – %s\n' % (key, action_descriptions[action])
814                 content += '\n'
815             if self.mode.name == 'chat':
816                 content += '/nick NAME – re-name yourself to NAME\n'
817                 content += '/%s or /play – switch to play mode\n' % self.keys['switch_to_play']
818                 content += '/%s or /study – switch to study mode\n' % self.keys['switch_to_study']
819                 content += '/%s or /edit – switch to world edit mode\n' % self.keys['switch_to_edit']
820                 content += '/%s or /admin – switch to admin mode\n' % self.keys['switch_to_admin_enter']
821             content += self.mode.list_available_modes(self)
822             for i in range(self.size.y):
823                 safe_addstr(i,
824                             self.window_width * (not self.mode.has_input_prompt),
825                             ' ' * self.window_width)
826             lines = []
827             for line in content.split('\n'):
828                 lines += msg_into_lines_of_width(line, self.window_width)
829             for i in range(len(lines)):
830                 if i >= self.size.y:
831                     break
832                 safe_addstr(i,
833                             self.window_width * (not self.mode.has_input_prompt),
834                             lines[i])
835
836         def draw_screen():
837             stdscr.clear()
838             recalc_input_lines()
839             if self.mode.has_input_prompt:
840                 draw_input()
841             if self.mode.shows_info:
842                 draw_info()
843             else:
844                 draw_history()
845             draw_mode()
846             if not self.mode.is_intro:
847                 draw_turn()
848                 draw_map()
849             if self.show_help:
850                 draw_help()
851
852         action_descriptions = {
853             'move': 'move',
854             'flatten': 'flatten surroundings',
855             'teleport': 'teleport',
856             'take_thing': 'pick up thing',
857             'drop_thing': 'drop thing',
858             'toggle_map_mode': 'toggle map view',
859             'toggle_tile_draw': 'toggle protection character drawing',
860         }
861
862         action_tasks = {
863             'flatten': 'FLATTEN_SURROUNDINGS',
864             'take_thing': 'PICK_UP',
865             'drop_thing': 'DROP',
866             'move': 'MOVE'
867         }
868
869         curses.curs_set(False)  # hide cursor
870         curses.use_default_colors()
871         stdscr.timeout(10)
872         reset_screen_size()
873         self.explorer = YX(0, 0)
874         self.input_ = ''
875         input_prompt = '> '
876         interval = datetime.timedelta(seconds=5)
877         last_ping = datetime.datetime.now() - interval
878         while True:
879             if self.disconnected and self.force_instant_connect:
880                 self.force_instant_connect = False
881                 self.connect()
882             now = datetime.datetime.now()
883             if now - last_ping > interval:
884                 if self.disconnected:
885                     self.connect()
886                 else:
887                     self.send('PING')
888                 last_ping = now
889             if self.flash:
890                 curses.flash()
891                 self.flash = False
892             if self.do_refresh:
893                 draw_screen()
894                 self.do_refresh = False
895             while True:
896                 try:
897                     msg = self.queue.get(block=False)
898                     handle_input(msg)
899                 except queue.Empty:
900                     break
901             try:
902                 key = stdscr.getkey()
903                 self.do_refresh = True
904             except curses.error:
905                 continue
906             self.show_help = False
907             if key == 'KEY_RESIZE':
908                 reset_screen_size()
909             elif self.mode.has_input_prompt and key == 'KEY_BACKSPACE':
910                 self.input_ = self.input_[:-1]
911             elif self.mode.has_input_prompt and key == '\n' and self.input_ == '/help':
912                 self.show_help = True
913                 self.input_ = ""
914                 self.restore_input_values()
915             elif self.mode.has_input_prompt and key != '\n':  # Return key
916                 self.input_ += key
917                 max_length = self.window_width * self.size.y - len(input_prompt) - 1
918                 if len(self.input_) > max_length:
919                     self.input_ = self.input_[:max_length]
920             elif key == self.keys['help'] and not self.mode.is_single_char_entry:
921                 self.show_help = True
922             elif self.mode.name == 'login' and key == '\n':
923                 self.login_name = self.input_
924                 self.send('LOGIN ' + quote(self.input_))
925                 self.input_ = ""
926             elif self.mode.name == 'control_pw_pw' and key == '\n':
927                 if self.input_ == '':
928                     self.log_msg('@ aborted')
929                 else:
930                     self.send('SET_MAP_CONTROL_PASSWORD ' + quote(self.tile_control_char) + ' ' + quote(self.input_))
931                     self.log_msg('@ sent new password for protection character "%s"' % self.tile_control_char)
932                 self.switch_mode('admin')
933             elif self.mode.name == 'password' and key == '\n':
934                 if self.input_ == '':
935                     self.input_ = ' '
936                 self.password = self.input_
937                 self.switch_mode('edit')
938             elif self.mode.name == 'admin_enter' and key == '\n':
939                 self.send('BECOME_ADMIN ' + quote(self.input_))
940                 self.switch_mode('play')
941             elif self.mode.name == 'control_pw_type' and key == '\n':
942                 if len(self.input_) != 1:
943                     self.log_msg('@ entered non-single-char, therefore aborted')
944                     self.switch_mode('admin')
945                 else:
946                     self.tile_control_char = self.input_
947                     self.switch_mode('control_pw_pw')
948             elif self.mode.name == 'admin_thing_protect' and key == '\n':
949                 if len(self.input_) != 1:
950                     self.log_msg('@ entered non-single-char, therefore aborted')
951                 else:
952                     self.send('THING_PROTECTION %s %s' % (self.thing_selected.id_,
953                                                           quote(self.input_)))
954                     self.log_msg('@ sent new protection character for thing')
955                 self.switch_mode('admin')
956             elif self.mode.name == 'control_tile_type' and key == '\n':
957                 if len(self.input_) != 1:
958                     self.log_msg('@ entered non-single-char, therefore aborted')
959                     self.switch_mode('admin')
960                 else:
961                     self.tile_control_char = self.input_
962                     self.switch_mode('control_tile_draw')
963             elif self.mode.name == 'chat' and key == '\n':
964                 if self.input_ == '':
965                     continue
966                 if self.input_[0] == '/':  # FIXME fails on empty input
967                     if self.input_ in {'/' + self.keys['switch_to_play'], '/play'}:
968                         self.switch_mode('play')
969                     elif self.input_ in {'/' + self.keys['switch_to_study'], '/study'}:
970                         self.switch_mode('study')
971                     elif self.input_ in {'/' + self.keys['switch_to_edit'], '/edit'}:
972                         self.switch_mode('edit')
973                     elif self.input_ in {'/' + self.keys['switch_to_admin_enter'], '/admin'}:
974                         self.switch_mode('admin_enter')
975                     elif self.input_.startswith('/nick'):
976                         tokens = self.input_.split(maxsplit=1)
977                         if len(tokens) == 2:
978                             self.send('NICK ' + quote(tokens[1]))
979                         else:
980                             self.log_msg('? need login name')
981                     else:
982                         self.log_msg('? unknown command')
983                 else:
984                     self.send('ALL ' + quote(self.input_))
985                 self.input_ = ""
986             elif self.mode.name == 'name_thing' and key == '\n':
987                 if self.input_ == '':
988                     self.input_ = ' '
989                 self.send('THING_NAME %s %s %s' % (self.thing_selected.id_,
990                                                    quote(self.input_),
991                                                    quote(self.password)))
992                 self.switch_mode('edit')
993             elif self.mode.name == 'annotate' and key == '\n':
994                 if self.input_ == '':
995                     self.input_ = ' '
996                 self.send('ANNOTATE %s %s %s' % (self.explorer, quote(self.input_),
997                                                  quote(self.password)))
998                 self.switch_mode('edit')
999             elif self.mode.name == 'portal' and key == '\n':
1000                 if self.input_ == '':
1001                     self.input_ = ' '
1002                 self.send('PORTAL %s %s %s' % (self.explorer, quote(self.input_),
1003                                                quote(self.password)))
1004                 self.switch_mode('edit')
1005             elif self.mode.name == 'study':
1006                 if self.mode.mode_switch_on_key(self, key):
1007                     continue
1008                 elif key == self.keys['toggle_map_mode']:
1009                     self.toggle_map_mode()
1010                 elif key in self.movement_keys:
1011                     move_explorer(self.movement_keys[key])
1012             elif self.mode.name == 'play':
1013                 if self.mode.mode_switch_on_key(self, key):
1014                     continue
1015                 elif key == self.keys['take_thing'] and task_action_on('take_thing'):
1016                     self.send('TASK:PICK_UP')
1017                 elif key == self.keys['drop_thing'] and task_action_on('drop_thing'):
1018                     self.send('TASK:DROP')
1019                 elif key == self.keys['teleport']:
1020                     player = self.game.get_thing(self.game.player_id)
1021                     if player.position in self.game.portals:
1022                         self.host = self.game.portals[player.position]
1023                         self.reconnect()
1024                     else:
1025                         self.flash = True
1026                         self.log_msg('? not standing on portal')
1027                 elif key in self.movement_keys and task_action_on('move'):
1028                     self.send('TASK:MOVE ' + self.movement_keys[key])
1029             elif self.mode.name == 'write':
1030                 self.send('TASK:WRITE %s %s' % (key, quote(self.password)))
1031                 self.switch_mode('edit')
1032             elif self.mode.name == 'control_tile_draw':
1033                 if self.mode.mode_switch_on_key(self, key):
1034                     continue
1035                 elif key in self.movement_keys:
1036                     move_explorer(self.movement_keys[key])
1037                 elif key == self.keys['toggle_tile_draw']:
1038                     self.tile_draw = False if self.tile_draw else True
1039             elif self.mode.name == 'admin':
1040                 if self.mode.mode_switch_on_key(self, key):
1041                     continue
1042                 elif key in self.movement_keys and task_action_on('move'):
1043                     self.send('TASK:MOVE ' + self.movement_keys[key])
1044             elif self.mode.name == 'edit':
1045                 if self.mode.mode_switch_on_key(self, key):
1046                     continue
1047                 elif key == self.keys['flatten'] and task_action_on('flatten'):
1048                     self.send('TASK:FLATTEN_SURROUNDINGS ' + quote(self.password))
1049                 elif key == self.keys['toggle_map_mode']:
1050                     self.toggle_map_mode()
1051                 elif key in self.movement_keys and task_action_on('move'):
1052                     self.send('TASK:MOVE ' + self.movement_keys[key])
1053
1054 if len(sys.argv) != 2:
1055     raise ArgError('wrong number of arguments, need game host')
1056 host = sys.argv[1]
1057 TUI(host)