home · contact · privacy
Add thing protection.
[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 map 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 map 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 tile protection character.  First enter the tile 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 tile protection character.  Enter the new password for the tile 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 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 tile 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 map 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 map 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 map edit password',
80         'long': 'This mode allows you to change the password that you send to authorize yourself for editing password-protected map tiles.  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.has_input_prompt = has_input_prompt
328         self.shows_info = shows_info
329         self.is_intro = is_intro
330         self.help_intro = mode_helps[name]['long']
331         self.is_single_char_entry = is_single_char_entry
332         self.legal = True
333
334     def iter_available_modes(self, tui):
335         for mode_name in self.available_modes:
336             mode = getattr(tui, 'mode_' + mode_name)
337             if not mode.legal:
338                 continue
339             key = tui.keys['switch_to_' + mode.name]
340             yield mode, key
341
342     def list_available_modes(self, tui):
343         msg = ''
344         if len(self.available_modes) > 0:
345             msg = 'Other modes available from here:\n'
346             for mode, key in self.iter_available_modes(tui):
347                 msg += '[%s] – %s\n' % (key, mode.short_desc)
348         return msg
349
350     def mode_switch_on_key(self, tui, key_pressed):
351         for mode, key in self.iter_available_modes(tui):
352             if key_pressed == key:
353                 tui.switch_mode(mode.name)
354                 return True
355         return False
356
357 class TUI:
358     mode_admin_enter = Mode('admin_enter', has_input_prompt=True)
359     mode_admin = Mode('admin')
360     mode_play = Mode('play')
361     mode_study = Mode('study', shows_info=True)
362     mode_write = Mode('write', is_single_char_entry=True)
363     mode_edit = Mode('edit')
364     mode_control_pw_type = Mode('control_pw_type', has_input_prompt=True)
365     mode_control_pw_pw = Mode('control_pw_pw', has_input_prompt=True)
366     mode_control_tile_type = Mode('control_tile_type', has_input_prompt=True)
367     mode_control_tile_draw = Mode('control_tile_draw')
368     mode_admin_thing_protect = Mode('admin_thing_protect', has_input_prompt=True)
369     mode_annotate = Mode('annotate', has_input_prompt=True, shows_info=True)
370     mode_portal = Mode('portal', has_input_prompt=True, shows_info=True)
371     mode_chat = Mode('chat', has_input_prompt=True)
372     mode_waiting_for_server = Mode('waiting_for_server', is_intro=True)
373     mode_login = Mode('login', has_input_prompt=True, is_intro=True)
374     mode_post_login_wait = Mode('post_login_wait', is_intro=True)
375     mode_password = Mode('password', has_input_prompt=True)
376     mode_name_thing = Mode('name_thing', has_input_prompt=True, shows_info=True)
377     is_admin = False
378     tile_draw = False
379
380     def __init__(self, host):
381         import os
382         import json
383         self.mode_play.available_modes = ["chat", "study", "edit", "admin_enter"]
384         self.mode_study.available_modes = ["chat", "play", "admin_enter", "edit"]
385         self.mode_admin.available_modes = ["admin_thing_protect", "control_pw_type",
386                                            "control_tile_type", "chat",
387                                            "study", "play", "edit"]
388         self.mode_control_tile_draw.available_modes = ["admin_enter"]
389         self.mode_edit.available_modes = ["write", "annotate", "portal", "name_thing",
390                                           "password", "chat", "study", "play",
391                                           "admin_enter"]
392         self.mode = None
393         self.host = host
394         self.game = Game()
395         self.game.tui = self
396         self.parser = Parser(self.game)
397         self.log = []
398         self.do_refresh = True
399         self.queue = queue.Queue()
400         self.login_name = None
401         self.map_mode = 'terrain + things'
402         self.password = 'foo'
403         self.switch_mode('waiting_for_server')
404         self.keys = {
405             'switch_to_chat': 't',
406             'switch_to_play': 'p',
407             'switch_to_password': 'P',
408             'switch_to_annotate': 'M',
409             'switch_to_portal': 'T',
410             'switch_to_study': '?',
411             'switch_to_edit': 'E',
412             'switch_to_write': 'm',
413             'switch_to_name_thing': 'N',
414             'switch_to_admin_enter': 'A',
415             'switch_to_control_pw_type': 'C',
416             'switch_to_control_tile_type': 'Q',
417             'switch_to_admin_thing_protect': 'T',
418             'flatten': 'F',
419             'take_thing': 'z',
420             'drop_thing': 'u',
421             'teleport': 'p',
422             'help': 'h',
423             'toggle_map_mode': 'L',
424             'toggle_tile_draw': 'm',
425             'hex_move_upleft': 'w',
426             'hex_move_upright': 'e',
427             'hex_move_right': 'd',
428             'hex_move_downright': 'x',
429             'hex_move_downleft': 'y',
430             'hex_move_left': 'a',
431             'square_move_up': 'w',
432             'square_move_left': 'a',
433             'square_move_down': 's',
434             'square_move_right': 'd',
435         }
436         if os.path.isfile('config.json'):
437             with open('config.json', 'r') as f:
438                 keys_conf = json.loads(f.read())
439             for k in keys_conf:
440                 self.keys[k] = keys_conf[k]
441         self.show_help = False
442         self.disconnected = True
443         self.force_instant_connect = True
444         self.input_lines = []
445         self.fov = ''
446         self.flash = False
447         curses.wrapper(self.loop)
448
449     def connect(self):
450
451         def handle_recv(msg):
452             if msg == 'BYE':
453                 self.socket.close()
454             else:
455                 self.queue.put(msg)
456
457         self.log_msg('@ attempting connect')
458         socket_client_class = PlomSocketClient
459         if self.host.startswith('ws://') or self.host.startswith('wss://'):
460             socket_client_class = WebSocketClient
461         try:
462             self.socket = socket_client_class(handle_recv, self.host)
463             self.socket_thread = threading.Thread(target=self.socket.run)
464             self.socket_thread.start()
465             self.disconnected = False
466             self.game.thing_types = {}
467             self.game.terrains = {}
468             time.sleep(0.1)  # give potential SSL negotation some time …
469             self.socket.send('TASKS')
470             self.socket.send('TERRAINS')
471             self.socket.send('THING_TYPES')
472             self.switch_mode('login')
473         except ConnectionRefusedError:
474             self.log_msg('@ server connect failure')
475             self.disconnected = True
476             self.switch_mode('waiting_for_server')
477         self.do_refresh = True
478
479     def reconnect(self):
480         self.log_msg('@ attempting reconnect')
481         self.send('QUIT')
482         # necessitated by some strange SSL race conditions with ws4py
483         time.sleep(0.1)  # FIXME find out why exactly necessary
484         self.switch_mode('waiting_for_server')
485         self.connect()
486
487     def send(self, msg):
488         try:
489             if hasattr(self.socket, 'plom_closed') and self.socket.plom_closed:
490                 raise BrokenSocketConnection
491             self.socket.send(msg)
492         except (BrokenPipeError, BrokenSocketConnection):
493             self.log_msg('@ server disconnected :(')
494             self.disconnected = True
495             self.force_instant_connect = True
496             self.do_refresh = True
497
498     def log_msg(self, msg):
499         self.log += [msg]
500         if len(self.log) > 100:
501             self.log = self.log[-100:]
502
503     def query_info(self):
504         self.send('GET_ANNOTATION ' + str(self.explorer))
505
506     def restore_input_values(self):
507         if self.mode.name == 'annotate' and self.explorer in self.game.info_db:
508             info = self.game.info_db[self.explorer]
509             if info != '(none)':
510                 self.input_ = info
511         elif self.mode.name == 'portal' and self.explorer in self.game.portals:
512             self.input_ = self.game.portals[self.explorer]
513         elif self.mode.name == 'password':
514             self.input_ = self.password
515         elif self.mode.name == 'name_thing':
516             if hasattr(self.thing_selected, 'name'):
517                 self.input_ = self.thing_selected.name
518         elif self.mode.name == 'admin_thing_protect':
519             if hasattr(self.thing_selected, 'protection'):
520                 self.input_ = self.thing_selected.protection
521
522     def send_tile_control_command(self):
523         self.send('SET_TILE_CONTROL %s %s' %
524                   (self.explorer, quote(self.tile_control_char)))
525
526     def toggle_map_mode(self):
527         if self.map_mode == 'terrain only':
528             self.map_mode = 'terrain + annotations'
529         elif self.map_mode == 'terrain + annotations':
530             self.map_mode = 'terrain + things'
531         elif self.map_mode == 'terrain + things':
532             self.map_mode = 'protections'
533         elif self.map_mode == 'protections':
534             self.map_mode = 'terrain only'
535
536     def switch_mode(self, mode_name):
537         self.tile_draw = False
538         if mode_name == 'admin_enter' and self.is_admin:
539             mode_name = 'admin'
540         elif mode_name in {'name_thing', 'admin_thing_protect'}:
541             player = self.game.get_thing(self.game.player_id)
542             thing = None
543             for t in [t for t in self.game.things if t.position == player.position
544                       and t.id_ != player.id_]:
545                 thing = t
546                 break
547             if not thing:
548                 self.flash = True
549                 self.log_msg('? not standing over thing')
550                 return
551             else:
552                 self.thing_selected = thing
553         self.mode = getattr(self, 'mode_' + mode_name)
554         if self.mode.name == 'control_tile_draw':
555             self.log_msg('@ finished tile protection drawing.')
556         if self.mode.name in {'control_tile_draw', 'control_tile_type',
557                               'control_pw_type'}:
558             self.map_mode = 'protections'
559         elif self.mode.name != 'edit':
560             self.map_mode = 'terrain + things'
561         if self.mode.shows_info or self.mode.name == 'control_tile_draw':
562             player = self.game.get_thing(self.game.player_id)
563             self.explorer = YX(player.position.y, player.position.x)
564             if self.mode.shows_info:
565                 self.query_info()
566         if self.mode.is_single_char_entry:
567             self.show_help = True
568         if self.mode.name == 'waiting_for_server':
569             self.log_msg('@ waiting for server …')
570         elif self.mode.name == 'login':
571             if self.login_name:
572                 self.send('LOGIN ' + quote(self.login_name))
573             else:
574                 self.log_msg('@ enter username')
575         elif self.mode.name == 'admin_enter':
576             self.log_msg('@ enter admin password:')
577         elif self.mode.name == 'control_pw_type':
578             self.log_msg('@ enter tile protection character for which you want to change the password:')
579         elif self.mode.name == 'control_tile_type':
580             self.log_msg('@ enter tile protection character which you want to draw:')
581         elif self.mode.name == 'admin_thing_protect':
582             self.log_msg('@ enter thing protection character:')
583         elif self.mode.name == 'control_pw_pw':
584             self.log_msg('@ enter tile protection password for "%s":' % self.tile_control_char)
585         elif self.mode.name == 'control_tile_draw':
586             self.log_msg('@ can draw tile 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']))
587         self.input_ = ""
588         self.restore_input_values()
589
590     def loop(self, stdscr):
591         import datetime
592
593         def safe_addstr(y, x, line):
594             if y < self.size.y - 1 or x + len(line) < self.size.x:
595                 stdscr.addstr(y, x, line)
596             else:  # workaround to <https://stackoverflow.com/q/7063128>
597                 cut_i = self.size.x - x - 1
598                 cut = line[:cut_i]
599                 last_char = line[cut_i]
600                 stdscr.addstr(y, self.size.x - 2, last_char)
601                 stdscr.insstr(y, self.size.x - 2, ' ')
602                 stdscr.addstr(y, x, cut)
603
604         def handle_input(msg):
605             command, args = self.parser.parse(msg)
606             command(*args)
607
608         def msg_into_lines_of_width(msg, width):
609             chunk = ''
610             lines = []
611             x = 0
612             for i in range(len(msg)):
613                 if x >= width or msg[i] == "\n":
614                     lines += [chunk]
615                     chunk = ''
616                     x = 0
617                     if msg[i] == "\n":
618                         x -= 1
619                 if msg[i] != "\n":
620                     chunk += msg[i]
621                 x += 1
622             lines += [chunk]
623             return lines
624
625         def reset_screen_size():
626             self.size = YX(*stdscr.getmaxyx())
627             self.size = self.size - YX(self.size.y % 4, 0)
628             self.size = self.size - YX(0, self.size.x % 4)
629             self.window_width = int(self.size.x / 2)
630
631         def recalc_input_lines():
632             if not self.mode.has_input_prompt:
633                 self.input_lines = []
634             else:
635                 self.input_lines = msg_into_lines_of_width(input_prompt + self.input_,
636                                                            self.window_width)
637
638         def move_explorer(direction):
639             target = self.game.map_geometry.move_yx(self.explorer, direction)
640             if target:
641                 self.explorer = target
642                 if self.mode.shows_info:
643                     self.query_info()
644                 if self.tile_draw:
645                     self.send_tile_control_command()
646             else:
647                 self.flash = True
648
649         def draw_history():
650             lines = []
651             for line in self.log:
652                 lines += msg_into_lines_of_width(line, self.window_width)
653             lines.reverse()
654             height_header = 2
655             max_y = self.size.y - len(self.input_lines)
656             for i in range(len(lines)):
657                 if (i >= max_y - height_header):
658                     break
659                 safe_addstr(max_y - i - 1, self.window_width, lines[i])
660
661         def draw_info():
662             if not self.game.turn_complete:
663                 return
664             pos_i = self.explorer.y * self.game.map_geometry.size.x + self.explorer.x
665             info = 'MAP VIEW: %s\n' % self.map_mode
666             if self.game.fov[pos_i] != '.':
667                 info += 'outside field of view'
668             else:
669                 terrain_char = self.game.map_content[pos_i]
670                 terrain_desc = '?'
671                 if terrain_char in self.game.terrains:
672                     terrain_desc = self.game.terrains[terrain_char]
673                 info += 'TERRAIN: "%s" / %s\n' % (terrain_char, terrain_desc)
674                 protection = self.game.map_control_content[pos_i]
675                 if protection == '.':
676                     protection = 'unprotected'
677                 info += 'PROTECTION: %s\n' % protection
678                 for t in self.game.things:
679                     if t.position == self.explorer:
680                         protection = t.protection
681                         if protection == '.':
682                             protection = 'unprotected'
683                         info += 'THING: %s / protection: %s / %s' %\
684                             (t.type_, protection, self.game.thing_types[t.type_])
685                         if hasattr(t, 'player_char'):
686                             info += t.player_char
687                         if hasattr(t, 'name'):
688                             info += ' (%s)' % t.name
689                         info += '\n'
690                 if self.explorer in self.game.portals:
691                     info += 'PORTAL: ' + self.game.portals[self.explorer] + '\n'
692                 else:
693                     info += 'PORTAL: (none)\n'
694                 if self.explorer in self.game.info_db:
695                     info += 'ANNOTATION: ' + self.game.info_db[self.explorer]
696                 else:
697                     info += 'ANNOTATION: waiting …'
698             lines = msg_into_lines_of_width(info, self.window_width)
699             height_header = 2
700             for i in range(len(lines)):
701                 y = height_header + i
702                 if y >= self.size.y - len(self.input_lines):
703                     break
704                 safe_addstr(y, self.window_width, lines[i])
705
706         def draw_input():
707             y = self.size.y - len(self.input_lines)
708             for i in range(len(self.input_lines)):
709                 safe_addstr(y, self.window_width, self.input_lines[i])
710                 y += 1
711
712         def draw_turn():
713             if not self.game.turn_complete:
714                 return
715             safe_addstr(0, self.window_width, 'TURN: ' + str(self.game.turn))
716
717         def draw_mode():
718             help = "hit [%s] for help" % self.keys['help']
719             if self.mode.has_input_prompt:
720                 help = "enter /help for help"
721             safe_addstr(1, self.window_width,
722                         'MODE: %s – %s' % (self.mode.short_desc, help))
723
724         def draw_map():
725             if not self.game.turn_complete:
726                 return
727             map_lines_split = []
728             for y in range(self.game.map_geometry.size.y):
729                 start = self.game.map_geometry.size.x * y
730                 end = start + self.game.map_geometry.size.x
731                 if self.map_mode == 'protections':
732                     map_lines_split += [[c + ' ' for c
733                                          in self.game.map_control_content[start:end]]]
734                 else:
735                     map_lines_split += [[c + ' ' for c
736                                          in self.game.map_content[start:end]]]
737             if self.map_mode == 'terrain + annotations':
738                 for p in self.game.info_hints:
739                     map_lines_split[p.y][p.x] = 'A '
740             elif self.map_mode == 'terrain + things':
741                 for p in self.game.portals.keys():
742                     original = map_lines_split[p.y][p.x]
743                     map_lines_split[p.y][p.x] = original[0] + 'P'
744                 used_positions = []
745                 for t in self.game.things:
746                     symbol = self.game.thing_types[t.type_]
747                     meta_char = ' '
748                     if hasattr(t, 'player_char'):
749                         meta_char = t.player_char
750                     if t.position in used_positions:
751                         meta_char = '+'
752                     map_lines_split[t.position.y][t.position.x] = symbol + meta_char
753                     used_positions += [t.position]
754             player = self.game.get_thing(self.game.player_id)
755             if self.mode.shows_info or self.mode.name == 'control_tile_draw':
756                 map_lines_split[self.explorer.y][self.explorer.x] = '??'
757             elif self.map_mode != 'terrain + things':
758                 map_lines_split[player.position.y][player.position.x] = '??'
759             map_lines = []
760             if type(self.game.map_geometry) == MapGeometryHex:
761                 indent = 0
762                 for line in map_lines_split:
763                     map_lines += [indent * ' ' + ''.join(line)]
764                     indent = 0 if indent else 1
765             else:
766                 for line in map_lines_split:
767                     map_lines += [''.join(line)]
768             window_center = YX(int(self.size.y / 2),
769                                int(self.window_width / 2))
770             center = player.position
771             if self.mode.shows_info or self.mode.name == 'control_tile_draw':
772                 center = self.explorer
773             center = YX(center.y, center.x * 2)
774             offset = center - window_center
775             if type(self.game.map_geometry) == MapGeometryHex and offset.y % 2:
776                 offset += YX(0, 1)
777             term_y = max(0, -offset.y)
778             term_x = max(0, -offset.x)
779             map_y = max(0, offset.y)
780             map_x = max(0, offset.x)
781             while (term_y < self.size.y and map_y < self.game.map_geometry.size.y):
782                 to_draw = map_lines[map_y][map_x:self.window_width + offset.x]
783                 safe_addstr(term_y, term_x, to_draw)
784                 term_y += 1
785                 map_y += 1
786
787         def draw_help():
788             content = "%s help\n\n%s\n\n" % (self.mode.short_desc,
789                                              self.mode.help_intro)
790             if self.mode.name == 'play':
791                 content += "Available actions:\n"
792                 if 'MOVE' in self.game.tasks:
793                     content += "[%s] – move player\n" % ','.join(self.movement_keys)
794                 if 'PICK_UP' in self.game.tasks:
795                     content += "[%s] – pick up thing\n" % self.keys['take_thing']
796                 if 'DROP' in self.game.tasks:
797                     content += "[%s] – drop thing\n" % self.keys['drop_thing']
798                 content += '[%s] – teleport\n' % self.keys['teleport']
799                 content += '\n'
800             elif self.mode.name == 'study':
801                 content += 'Available actions:\n'
802                 content += '[%s] – move question mark\n' % ','.join(self.movement_keys)
803                 content += '[%s] – toggle map view\n' % self.keys['toggle_map_mode']
804                 content += '\n'
805             elif self.mode.name == 'edit':
806                 content += "Available actions:\n"
807                 if 'MOVE' in self.game.tasks:
808                     content += "[%s] – move player\n" % ','.join(self.movement_keys)
809                 if 'FLATTEN_SURROUNDINGS' in self.game.tasks:
810                     content += "[%s] – flatten surroundings\n" % self.keys['flatten']
811                 content += '[%s] – toggle map view\n' % self.keys['toggle_map_mode']
812                 content += '\n'
813             elif self.mode.name == 'control_tile_draw':
814                 content += "Available actions:\n"
815                 content += "[%s] – toggle tile protection drawing\n" % self.keys['toggle_tile_draw']
816                 content += '\n'
817             elif self.mode.name == 'chat':
818                 content += '/nick NAME – re-name yourself to NAME\n'
819                 content += '/%s or /play – switch to play mode\n' % self.keys['switch_to_play']
820                 content += '/%s or /study – switch to study mode\n' % self.keys['switch_to_study']
821                 content += '/%s or /edit – switch to map edit mode\n' % self.keys['switch_to_edit']
822                 content += '/%s or /admin – switch to admin mode\n' % self.keys['switch_to_admin_enter']
823             content += self.mode.list_available_modes(self)
824             for i in range(self.size.y):
825                 safe_addstr(i,
826                             self.window_width * (not self.mode.has_input_prompt),
827                             ' ' * self.window_width)
828             lines = []
829             for line in content.split('\n'):
830                 lines += msg_into_lines_of_width(line, self.window_width)
831             for i in range(len(lines)):
832                 if i >= self.size.y:
833                     break
834                 safe_addstr(i,
835                             self.window_width * (not self.mode.has_input_prompt),
836                             lines[i])
837
838         def draw_screen():
839             stdscr.clear()
840             recalc_input_lines()
841             if self.mode.has_input_prompt:
842                 draw_input()
843             if self.mode.shows_info:
844                 draw_info()
845             else:
846                 draw_history()
847             draw_mode()
848             if not self.mode.is_intro:
849                 draw_turn()
850                 draw_map()
851             if self.show_help:
852                 draw_help()
853
854         curses.curs_set(False)  # hide cursor
855         curses.use_default_colors()
856         stdscr.timeout(10)
857         reset_screen_size()
858         self.explorer = YX(0, 0)
859         self.input_ = ''
860         input_prompt = '> '
861         interval = datetime.timedelta(seconds=5)
862         last_ping = datetime.datetime.now() - interval
863         while True:
864             if self.disconnected and self.force_instant_connect:
865                 self.force_instant_connect = False
866                 self.connect()
867             now = datetime.datetime.now()
868             if now - last_ping > interval:
869                 if self.disconnected:
870                     self.connect()
871                 else:
872                     self.send('PING')
873                 last_ping = now
874             if self.flash:
875                 curses.flash()
876                 self.flash = False
877             if self.do_refresh:
878                 draw_screen()
879                 self.do_refresh = False
880             while True:
881                 try:
882                     msg = self.queue.get(block=False)
883                     handle_input(msg)
884                 except queue.Empty:
885                     break
886             try:
887                 key = stdscr.getkey()
888                 self.do_refresh = True
889             except curses.error:
890                 continue
891             self.show_help = False
892             if key == 'KEY_RESIZE':
893                 reset_screen_size()
894             elif self.mode.has_input_prompt and key == 'KEY_BACKSPACE':
895                 self.input_ = self.input_[:-1]
896             elif self.mode.has_input_prompt and key == '\n' and self.input_ == '/help':
897                 self.show_help = True
898                 self.input_ = ""
899                 self.restore_input_values()
900             elif self.mode.has_input_prompt and key != '\n':  # Return key
901                 self.input_ += key
902                 max_length = self.window_width * self.size.y - len(input_prompt) - 1
903                 if len(self.input_) > max_length:
904                     self.input_ = self.input_[:max_length]
905             elif key == self.keys['help'] and not self.mode.is_single_char_entry:
906                 self.show_help = True
907             elif self.mode.name == 'login' and key == '\n':
908                 self.login_name = self.input_
909                 self.send('LOGIN ' + quote(self.input_))
910                 self.input_ = ""
911             elif self.mode.name == 'control_pw_pw' and key == '\n':
912                 if self.input_ == '':
913                     self.log_msg('@ aborted')
914                 else:
915                     self.send('SET_MAP_CONTROL_PASSWORD ' + quote(self.tile_control_char) + ' ' + quote(self.input_))
916                     self.log_msg('@ sent new password for protection character "%s"' % self.tile_control_char)
917                 self.switch_mode('admin')
918             elif self.mode.name == 'password' and key == '\n':
919                 if self.input_ == '':
920                     self.input_ = ' '
921                 self.password = self.input_
922                 self.switch_mode('edit')
923             elif self.mode.name == 'admin_enter' and key == '\n':
924                 self.send('BECOME_ADMIN ' + quote(self.input_))
925                 self.switch_mode('play')
926             elif self.mode.name == 'control_pw_type' and key == '\n':
927                 if len(self.input_) != 1:
928                     self.log_msg('@ entered non-single-char, therefore aborted')
929                     self.switch_mode('admin')
930                 else:
931                     self.tile_control_char = self.input_
932                     self.switch_mode('control_pw_pw')
933             elif self.mode.name == 'admin_thing_protect' and key == '\n':
934                 if len(self.input_) != 1:
935                     self.log_msg('@ entered non-single-char, therefore aborted')
936                 else:
937                     self.send('THING_PROTECTION %s %s' % (self.thing_selected.id_,
938                                                           quote(self.input_)))
939                     self.log_msg('@ sent new protection character for thing')
940                 self.switch_mode('admin')
941             elif self.mode.name == 'control_tile_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_tile_draw')
948             elif self.mode.name == 'chat' and key == '\n':
949                 if self.input_ == '':
950                     continue
951                 if self.input_[0] == '/':  # FIXME fails on empty input
952                     if self.input_ in {'/' + self.keys['switch_to_play'], '/play'}:
953                         self.switch_mode('play')
954                     elif self.input_ in {'/' + self.keys['switch_to_study'], '/study'}:
955                         self.switch_mode('study')
956                     elif self.input_ in {'/' + self.keys['switch_to_edit'], '/edit'}:
957                         self.switch_mode('edit')
958                     elif self.input_ in {'/' + self.keys['switch_to_admin_enter'], '/admin'}:
959                         self.switch_mode('admin_enter')
960                     elif self.input_.startswith('/nick'):
961                         tokens = self.input_.split(maxsplit=1)
962                         if len(tokens) == 2:
963                             self.send('NICK ' + quote(tokens[1]))
964                         else:
965                             self.log_msg('? need login name')
966                     else:
967                         self.log_msg('? unknown command')
968                 else:
969                     self.send('ALL ' + quote(self.input_))
970                 self.input_ = ""
971             elif self.mode.name == 'name_thing' and key == '\n':
972                 if self.input_ == '':
973                     self.input_ = ' '
974                 self.send('THING_NAME %s %s %s' % (self.thing_selected.id_,
975                                                    quote(self.input_),
976                                                    quote(self.password)))
977                 self.switch_mode('edit')
978             elif self.mode.name == 'annotate' and key == '\n':
979                 if self.input_ == '':
980                     self.input_ = ' '
981                 self.send('ANNOTATE %s %s %s' % (self.explorer, quote(self.input_),
982                                                  quote(self.password)))
983                 self.switch_mode('edit')
984             elif self.mode.name == 'portal' and key == '\n':
985                 if self.input_ == '':
986                     self.input_ = ' '
987                 self.send('PORTAL %s %s %s' % (self.explorer, quote(self.input_),
988                                                quote(self.password)))
989                 self.switch_mode('edit')
990             elif self.mode.name == 'study':
991                 if self.mode.mode_switch_on_key(self, key):
992                     continue
993                 elif key == self.keys['toggle_map_mode']:
994                     self.toggle_map_mode()
995                 elif key in self.movement_keys:
996                     move_explorer(self.movement_keys[key])
997             elif self.mode.name == 'play':
998                 if self.mode.mode_switch_on_key(self, key):
999                     continue
1000                 elif key == self.keys['take_thing'] and 'PICK_UP' in self.game.tasks:
1001                     self.send('TASK:PICK_UP')
1002                 elif key == self.keys['drop_thing'] and 'DROP' in self.game.tasks:
1003                     self.send('TASK:DROP')
1004                 elif key == self.keys['teleport']:
1005                     player = self.game.get_thing(self.game.player_id)
1006                     if player.position in self.game.portals:
1007                         self.host = self.game.portals[player.position]
1008                         self.reconnect()
1009                     else:
1010                         self.flash = True
1011                         self.log_msg('? not standing on portal')
1012                 elif key in self.movement_keys and 'MOVE' in self.game.tasks:
1013                     self.send('TASK:MOVE ' + self.movement_keys[key])
1014             elif self.mode.name == 'write':
1015                 self.send('TASK:WRITE %s %s' % (key, quote(self.password)))
1016                 self.switch_mode('edit')
1017             elif self.mode.name == 'control_tile_draw':
1018                 if self.mode.mode_switch_on_key(self, key):
1019                     continue
1020                 elif key in self.movement_keys:
1021                     move_explorer(self.movement_keys[key])
1022                 elif key == self.keys['toggle_tile_draw']:
1023                     self.tile_draw = False if self.tile_draw else True
1024             elif self.mode.name == 'admin':
1025                 if self.mode.mode_switch_on_key(self, key):
1026                     continue
1027             elif self.mode.name == 'edit':
1028                 if self.mode.mode_switch_on_key(self, key):
1029                     continue
1030                 elif key == self.keys['flatten'] and\
1031                     'FLATTEN_SURROUNDINGS' in self.game.tasks:
1032                     self.send('TASK:FLATTEN_SURROUNDINGS ' + quote(self.password))
1033                 elif key == self.keys['toggle_map_mode']:
1034                     self.toggle_map_mode()
1035                 elif key in self.movement_keys and 'MOVE' in self.game.tasks:
1036                     self.send('TASK:MOVE ' + self.movement_keys[key])
1037
1038 if len(sys.argv) != 2:
1039     raise ArgError('wrong number of arguments, need game host')
1040 host = sys.argv[1]
1041 TUI(host)