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