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