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