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