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