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