5 from plomrogue.game import GameBase
6 from plomrogue.parser import Parser
7 from plomrogue.mapping import YX, MapGeometrySquare, MapGeometryHex
8 from plomrogue.things import ThingBase
9 from plomrogue.misc import quote
10 from plomrogue.errors import BrokenSocketConnection
12 from ws4py.client import WebSocketBaseClient
13 class WebSocketClient(WebSocketBaseClient):
15 def __init__(self, recv_handler, *args, **kwargs):
16 super().__init__(*args, **kwargs)
17 self.recv_handler = recv_handler
20 def received_message(self, message):
22 message = str(message)
23 self.recv_handler(message)
26 def plom_closed(self):
27 return self.client_terminated
29 from plomrogue.io_tcp import PlomSocket
30 class PlomSocketClient(PlomSocket):
32 def __init__(self, recv_handler, url):
34 self.recv_handler = recv_handler
35 host, port = url.split(':')
36 super().__init__(socket.create_connection((host, port)))
44 for msg in self.recv():
46 self.socket = ssl.wrap_socket(self.socket)
48 self.recv_handler(msg)
49 except BrokenSocketConnection:
50 pass # we assume socket will be known as dead by now
52 def cmd_TURN(game, n):
56 game.turn_complete = False
57 cmd_TURN.argtypes = 'int:nonneg'
59 def cmd_LOGIN_OK(game):
60 game.tui.switch_mode('post_login_wait')
61 game.tui.send('GET_GAMESTATE')
62 game.tui.log_msg('@ welcome')
63 cmd_LOGIN_OK.argtypes = ''
65 def cmd_CHAT(game, msg):
66 game.tui.log_msg('# ' + msg)
67 game.tui.do_refresh = True
68 cmd_CHAT.argtypes = 'string'
70 def cmd_PLAYER_ID(game, player_id):
71 game.player_id = player_id
72 cmd_PLAYER_ID.argtypes = 'int:nonneg'
74 def cmd_THING_POS(game, thing_id, position):
75 t = game.get_thing(thing_id, True)
77 cmd_THING_POS.argtypes = 'int:nonneg yx_tuple:nonneg'
79 def cmd_THING_NAME(game, thing_id, name):
80 t = game.get_thing(thing_id, True)
82 cmd_THING_NAME.argtypes = 'int:nonneg string'
84 def cmd_MAP(game, geometry, size, content):
85 map_geometry_class = globals()['MapGeometry' + geometry]
86 game.map_geometry = map_geometry_class(size)
87 game.map_content = content
88 if type(game.map_geometry) == MapGeometrySquare:
89 game.tui.movement_keys = {
90 game.tui.keys['square_move_up']: 'UP',
91 game.tui.keys['square_move_left']: 'LEFT',
92 game.tui.keys['square_move_down']: 'DOWN',
93 game.tui.keys['square_move_right']: 'RIGHT',
95 elif type(game.map_geometry) == MapGeometryHex:
96 game.tui.movement_keys = {
97 game.tui.keys['hex_move_upleft']: 'UPLEFT',
98 game.tui.keys['hex_move_upright']: 'UPRIGHT',
99 game.tui.keys['hex_move_right']: 'RIGHT',
100 game.tui.keys['hex_move_downright']: 'DOWNRIGHT',
101 game.tui.keys['hex_move_downleft']: 'DOWNLEFT',
102 game.tui.keys['hex_move_left']: 'LEFT',
104 cmd_MAP.argtypes = 'string:map_geometry yx_tuple:pos string'
106 def cmd_MAP_CONTROL(game, content):
107 game.map_control_content = content
108 cmd_MAP_CONTROL.argtypes = 'string'
110 def cmd_GAME_STATE_COMPLETE(game):
112 if game.tui.mode.name == 'post_login_wait':
113 game.tui.switch_mode('play')
114 if game.tui.mode.shows_info:
115 game.tui.query_info()
116 player = game.get_thing(game.player_id, False)
117 if player.position in game.portals:
118 game.tui.teleport_target_host = game.portals[player.position]
119 game.tui.switch_mode('teleport')
120 game.turn_complete = True
121 game.tui.do_refresh = True
122 cmd_GAME_STATE_COMPLETE.argtypes = ''
124 def cmd_PORTAL(game, position, msg):
125 game.portals[position] = msg
126 cmd_PORTAL.argtypes = 'yx_tuple:nonneg string'
128 def cmd_PLAY_ERROR(game, msg):
130 game.tui.do_refresh = True
131 cmd_PLAY_ERROR.argtypes = 'string'
133 def cmd_GAME_ERROR(game, msg):
134 game.tui.log_msg('? game error: ' + msg)
135 game.tui.do_refresh = True
136 cmd_GAME_ERROR.argtypes = 'string'
138 def cmd_ARGUMENT_ERROR(game, msg):
139 game.tui.log_msg('? syntax error: ' + msg)
140 game.tui.do_refresh = True
141 cmd_ARGUMENT_ERROR.argtypes = 'string'
143 def cmd_ANNOTATION(game, position, msg):
144 game.info_db[position] = msg
145 if game.tui.mode.shows_info:
146 game.tui.do_refresh = True
147 cmd_ANNOTATION.argtypes = 'yx_tuple:nonneg string'
149 def cmd_TASKS(game, tasks_comma_separated):
150 game.tasks = tasks_comma_separated.split(',')
151 cmd_TASKS.argtypes = 'string'
155 cmd_PONG.argtypes = ''
157 class Game(GameBase):
158 thing_type = ThingBase
159 turn_complete = False
162 def __init__(self, *args, **kwargs):
163 super().__init__(*args, **kwargs)
164 self.register_command(cmd_LOGIN_OK)
165 self.register_command(cmd_PONG)
166 self.register_command(cmd_CHAT)
167 self.register_command(cmd_PLAYER_ID)
168 self.register_command(cmd_TURN)
169 self.register_command(cmd_THING_POS)
170 self.register_command(cmd_THING_NAME)
171 self.register_command(cmd_MAP)
172 self.register_command(cmd_MAP_CONTROL)
173 self.register_command(cmd_PORTAL)
174 self.register_command(cmd_ANNOTATION)
175 self.register_command(cmd_GAME_STATE_COMPLETE)
176 self.register_command(cmd_ARGUMENT_ERROR)
177 self.register_command(cmd_GAME_ERROR)
178 self.register_command(cmd_PLAY_ERROR)
179 self.register_command(cmd_TASKS)
180 self.map_content = ''
185 def get_string_options(self, string_option_type):
186 if string_option_type == 'map_geometry':
187 return ['Hex', 'Square']
190 def get_command(self, command_name):
191 from functools import partial
192 f = partial(self.commands[command_name], self)
193 f.argtypes = self.commands[command_name].argtypes
200 def __init__(self, name, help_intro, has_input_prompt=False,
201 shows_info=False, is_intro = False):
203 self.has_input_prompt = has_input_prompt
204 self.shows_info = shows_info
205 self.is_intro = is_intro
206 self.help_intro = help_intro
208 def __init__(self, host):
212 self.mode_play = self.Mode('play', 'This mode allows you to interact with the map.')
213 self.mode_study = self.Mode('study', '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 (unless obscured by this help screen here, which you can disappear with any key).', shows_info=True)
214 self.mode_edit = self.Mode('edit', '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.')
215 self.mode_annotate = self.Mode('annotate', '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.', has_input_prompt=True, shows_info=True)
216 self.mode_portal = self.Mode('portal', '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.', has_input_prompt=True, shows_info=True)
217 self.mode_chat = self.Mode('chat', '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 to all users. Lines that start with a "/" are used for commands like:', has_input_prompt=True)
218 self.mode_waiting_for_server = self.Mode('waiting_for_server', 'Waiting for a server response.', is_intro=True)
219 self.mode_login = self.Mode('login', 'Pick your player name.', has_input_prompt=True, is_intro=True)
220 self.mode_post_login_wait = self.Mode('post_login_wait', 'Waiting for a server response.', is_intro=True)
221 self.mode_teleport = self.Mode('teleport', 'Follow the instructions to re-connect and log-in to another server, or enter anything else to abort.', has_input_prompt=True)
222 self.mode_password = self.Mode('password', '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.', has_input_prompt=True)
225 self.parser = Parser(self.game)
227 self.do_refresh = True
228 self.queue = queue.Queue()
229 self.login_name = None
230 self.map_mode = 'terrain'
231 self.password = 'foo'
232 self.switch_mode('waiting_for_server')
234 'switch_to_chat': 't',
235 'switch_to_play': 'p',
236 'switch_to_password': 'p',
237 'switch_to_annotate': 'm',
238 'switch_to_portal': 'P',
239 'switch_to_study': '?',
240 'switch_to_edit': 'm',
242 'toggle_map_mode': 'M',
243 'hex_move_upleft': 'w',
244 'hex_move_upright': 'e',
245 'hex_move_right': 'd',
246 'hex_move_downright': 'x',
247 'hex_move_downleft': 'y',
248 'hex_move_left': 'a',
249 'square_move_up': 'w',
250 'square_move_left': 'a',
251 'square_move_down': 's',
252 'square_move_right': 'd',
254 if os.path.isfile('config.json'):
255 with open('config.json', 'r') as f:
256 keys_conf = json.loads(f.read())
258 self.keys[k] = keys_conf[k]
259 self.show_help = False
260 curses.wrapper(self.loop)
267 if hasattr(self.socket, 'plom_closed') and self.socket.plom_closed:
268 raise BrokenSocketConnection
269 self.socket.send(msg)
270 except (BrokenPipeError, BrokenSocketConnection):
271 self.log_msg('@ server disconnected :(')
272 self.do_refresh = True
274 def log_msg(self, msg):
276 if len(self.log) > 100:
277 self.log = self.log[-100:]
279 def query_info(self):
280 self.send('GET_ANNOTATION ' + str(self.explorer))
282 def restore_input_values(self):
283 if self.mode.name == 'annotate' and self.explorer in self.game.info_db:
284 info = self.game.info_db[self.explorer]
287 elif self.mode.name == 'portal' and self.explorer in self.game.portals:
288 self.input_ = self.game.portals[self.explorer]
289 elif self.mode.name == 'password':
290 self.input_ = self.password
292 def switch_mode(self, mode_name, keep_position = False):
293 self.map_mode = 'terrain'
294 self.mode = getattr(self, 'mode_' + mode_name)
295 if self.mode.shows_info and not keep_position:
296 player = self.game.get_thing(self.game.player_id, False)
297 self.explorer = YX(player.position.y, player.position.x)
298 if self.mode.name == 'waiting_for_server':
299 self.log_msg('@ waiting for server …')
300 if self.mode.name == 'edit':
301 self.show_help = True
302 elif self.mode.name == 'login':
304 self.send('LOGIN ' + quote(self.login_name))
306 self.log_msg('@ enter username')
307 elif self.mode.name == 'teleport':
308 self.log_msg("@ May teleport to %s" % (self.teleport_target_host)),
309 self.log_msg("@ Enter 'YES!' to enthusiastically affirm.");
310 self.restore_input_values()
312 def loop(self, stdscr):
316 def safe_addstr(y, x, line):
317 if y < self.size.y - 1 or x + len(line) < self.size.x:
318 stdscr.addstr(y, x, line)
319 else: # workaround to <https://stackoverflow.com/q/7063128>
320 cut_i = self.size.x - x - 1
322 last_char = line[cut_i]
323 stdscr.addstr(y, self.size.x - 2, last_char)
324 stdscr.insstr(y, self.size.x - 2, ' ')
325 stdscr.addstr(y, x, cut)
329 def handle_recv(msg):
335 socket_client_class = PlomSocketClient
336 if self.host.startswith('ws://') or self.host.startswith('wss://'):
337 socket_client_class = WebSocketClient
340 self.socket = socket_client_class(handle_recv, self.host)
341 self.socket_thread = threading.Thread(target=self.socket.run)
342 self.socket_thread.start()
343 self.socket.send('TASKS')
344 self.switch_mode('login')
346 except ConnectionRefusedError:
347 self.log_msg('@ server connect failure, trying again …')
354 time.sleep(0.1) # FIXME necessitated by some some strange SSL race
355 # conditions with ws4py, find out what exactly
356 self.switch_mode('waiting_for_server')
359 def handle_input(msg):
360 command, args = self.parser.parse(msg)
363 def msg_into_lines_of_width(msg, width):
367 for i in range(len(msg)):
368 if x >= width or msg[i] == "\n":
378 def reset_screen_size():
379 self.size = YX(*stdscr.getmaxyx())
380 self.size = self.size - YX(self.size.y % 4, 0)
381 self.size = self.size - YX(0, self.size.x % 4)
382 self.window_width = int(self.size.x / 2)
384 def recalc_input_lines():
385 if not self.mode.has_input_prompt:
386 self.input_lines = []
388 self.input_lines = msg_into_lines_of_width(input_prompt + self.input_,
391 def move_explorer(direction):
392 target = self.game.map_geometry.move(self.explorer, direction)
394 self.explorer = target
401 for line in self.log:
402 lines += msg_into_lines_of_width(line, self.window_width)
405 max_y = self.size.y - len(self.input_lines)
406 for i in range(len(lines)):
407 if (i >= max_y - height_header):
409 safe_addstr(max_y - i - 1, self.window_width, lines[i])
412 if not self.game.turn_complete:
414 pos_i = self.explorer.y * self.game.map_geometry.size.x + self.explorer.x
415 info = 'TERRAIN: %s\n' % self.game.map_content[pos_i]
416 for t in self.game.things:
417 if t.position == self.explorer:
418 info += 'PLAYER @: %s\n' % t.name
419 if self.explorer in self.game.portals:
420 info += 'PORTAL: ' + self.game.portals[self.explorer] + '\n'
422 info += 'PORTAL: (none)\n'
423 if self.explorer in self.game.info_db:
424 info += 'ANNOTATION: ' + self.game.info_db[self.explorer]
426 info += 'ANNOTATION: waiting …'
427 lines = msg_into_lines_of_width(info, self.window_width)
429 for i in range(len(lines)):
430 y = height_header + i
431 if y >= self.size.y - len(self.input_lines):
433 safe_addstr(y, self.window_width, lines[i])
436 y = self.size.y - len(self.input_lines)
437 for i in range(len(self.input_lines)):
438 safe_addstr(y, self.window_width, self.input_lines[i])
442 if not self.game.turn_complete:
444 safe_addstr(0, self.window_width, 'TURN: ' + str(self.game.turn))
447 help = "hit [%s] for help" % self.keys['help']
448 if self.mode.has_input_prompt:
449 help = "enter /help for help"
450 safe_addstr(1, self.window_width, 'MODE: %s – %s' % (self.mode.name, help))
453 if not self.game.turn_complete:
456 map_content = self.game.map_content
457 if self.map_mode == 'control':
458 map_content = self.game.map_control_content
459 for y in range(self.game.map_geometry.size.y):
460 start = self.game.map_geometry.size.x * y
461 end = start + self.game.map_geometry.size.x
462 map_lines_split += [list(map_content[start:end])]
463 if self.map_mode == 'terrain':
464 for t in self.game.things:
465 map_lines_split[t.position.y][t.position.x] = '@'
466 if self.mode.shows_info:
467 map_lines_split[self.explorer.y][self.explorer.x] = '?'
469 if type(self.game.map_geometry) == MapGeometryHex:
471 for line in map_lines_split:
472 map_lines += [indent*' ' + ' '.join(line)]
473 indent = 0 if indent else 1
475 for line in map_lines_split:
476 map_lines += [' '.join(line)]
477 window_center = YX(int(self.size.y / 2),
478 int(self.window_width / 2))
479 player = self.game.get_thing(self.game.player_id, False)
480 center = player.position
481 if self.mode.shows_info:
482 center = self.explorer
483 center = YX(center.y, center.x * 2)
484 offset = center - window_center
485 if type(self.game.map_geometry) == MapGeometryHex and offset.y % 2:
487 term_y = max(0, -offset.y)
488 term_x = max(0, -offset.x)
489 map_y = max(0, offset.y)
490 map_x = max(0, offset.x)
491 while (term_y < self.size.y and map_y < self.game.map_geometry.size.y):
492 to_draw = map_lines[map_y][map_x:self.window_width + offset.x]
493 safe_addstr(term_y, term_x, to_draw)
498 content = "%s mode help (hit any key to disappear)\n\n%s\n\n" % (self.mode.name,
499 self.mode.help_intro)
500 if self.mode == self.mode_play:
501 content += "Available actions:\n"
502 if 'MOVE' in self.game.tasks:
503 content += "[%s] – move player\n" % ','.join(self.movement_keys)
504 if 'FLATTEN_SURROUNDINGS' in self.game.tasks:
505 content += "[%s] – flatten player's surroundings\n" % self.keys['flatten']
506 content += 'Other modes available from here:\n'
507 content += '[%s] – terrain edit mode\n' % self.keys['switch_to_edit']
508 content += '[%s] – terrain password edit mode\n' % self.keys['switch_to_password']
509 content += '[%s] – chat mode\n' % self.keys['switch_to_chat']
510 content += '[%s] – study mode\n' % self.keys['switch_to_study']
511 elif self.mode == self.mode_study:
512 content += 'Available actions:\n'
513 content += '[%s] – move question mark\n' % ','.join(self.movement_keys)
514 content += '[%s] – toggle view between terrain, and password protection areas\n' % self.keys['toggle_map_mode']
515 content += '\n\nOther modes available from here:'
516 content += '[%s] – chat mode\n' % self.keys['switch_to_chat']
517 content += '[%s] – play mode\n' % self.keys['switch_to_play']
518 content += '[%s] – portal mode\n' % self.keys['switch_to_portal']
519 content += '[%s] – annotation mode\n' % self.keys['switch_to_annotate']
520 elif self.mode == self.mode_chat:
521 content += '/nick NAME – re-name yourself to NAME\n'
522 content += '/msg USER TEXT – send TEXT to USER\n'
523 content += '/%s or /play – switch to play mode\n' % self.keys['switch_to_play']
524 content += '/%s or /study – switch to study mode\n' % self.keys['switch_to_study']
525 for i in range(self.size.y):
527 self.window_width * (not self.mode.has_input_prompt),
528 ' '*self.window_width)
530 for line in content.split('\n'):
531 lines += msg_into_lines_of_width(line, self.window_width)
532 for i in range(len(lines)):
536 self.window_width * (not self.mode.has_input_prompt),
541 if self.mode.has_input_prompt:
544 if self.mode.shows_info:
549 if not self.mode.is_intro:
555 curses.curs_set(False) # hide cursor
556 curses.use_default_colors();
559 self.explorer = YX(0, 0)
563 last_ping = datetime.datetime.now()
564 interval = datetime.timedelta(seconds=30)
566 now = datetime.datetime.now()
567 if now - last_ping > interval:
572 self.do_refresh = False
575 msg = self.queue.get(block=False)
580 key = stdscr.getkey()
581 self.do_refresh = True
584 self.show_help = False
585 if key == 'KEY_RESIZE':
587 elif self.mode.has_input_prompt and key == 'KEY_BACKSPACE':
588 self.input_ = self.input_[:-1]
589 elif self.mode.has_input_prompt and key == '\n' and self.input_ == '/help':
590 self.show_help = True
592 self.restore_input_values()
593 elif self.mode.has_input_prompt and key != '\n': # Return key
595 max_length = self.window_width * self.size.y - len(input_prompt) - 1
596 if len(self.input_) > max_length:
597 self.input_ = self.input_[:max_length]
598 elif key == self.keys['help'] and self.mode != self.mode_edit:
599 self.show_help = True
600 elif self.mode == self.mode_login and key == '\n':
601 self.login_name = self.input_
602 self.send('LOGIN ' + quote(self.input_))
604 elif self.mode == self.mode_password and key == '\n':
605 if self.input_ == '':
607 self.password = self.input_
609 self.switch_mode('play')
610 elif self.mode == self.mode_chat and key == '\n':
611 if self.input_[0] == '/':
612 if self.input_ in {'/' + self.keys['switch_to_play'], '/play'}:
613 self.switch_mode('play')
614 elif self.input_ in {'/' + self.keys['switch_to_study'], '/study'}:
615 self.switch_mode('study')
616 elif self.input_ == '/reconnect':
618 elif self.input_.startswith('/nick'):
619 tokens = self.input_.split(maxsplit=1)
621 self.send('NICK ' + quote(tokens[1]))
623 self.log_msg('? need login name')
624 elif self.input_.startswith('/msg'):
625 tokens = self.input_.split(maxsplit=2)
627 self.send('QUERY %s %s' % (quote(tokens[1]),
630 self.log_msg('? need message target and message')
632 self.log_msg('? unknown command')
634 self.send('ALL ' + quote(self.input_))
636 elif self.mode == self.mode_annotate and key == '\n':
637 if self.input_ == '':
639 self.send('ANNOTATE %s %s %s' % (self.explorer, quote(self.input_),
640 quote(self.password))
642 self.switch_mode('study', keep_position=True)
643 elif self.mode == self.mode_portal and key == '\n':
644 if self.input_ == '':
646 self.send('PORTAL %s %s %s' % (self.explorer, quote(self.input_),
647 quote(self.password)))
649 self.switch_mode('study', keep_position=True)
650 elif self.mode == self.mode_teleport and key == '\n':
651 if self.input_ == 'YES!':
652 self.host = self.teleport_target_host
655 self.log_msg('@ teleport aborted')
656 self.switch_mode('play')
658 elif self.mode == self.mode_study:
659 if key == self.keys['switch_to_chat']:
660 self.switch_mode('chat')
661 elif key == self.keys['switch_to_play']:
662 self.switch_mode('play')
663 elif key == self.keys['switch_to_annotate']:
664 self.switch_mode('annotate', keep_position=True)
665 elif key == self.keys['switch_to_portal']:
666 self.switch_mode('portal', keep_position=True)
667 elif key == self.keys['toggle_map_mode']:
668 if self.map_mode == 'terrain':
669 self.map_mode = 'control'
671 self.map_mode = 'terrain'
672 elif key in self.movement_keys:
673 move_explorer(self.movement_keys[key])
674 elif self.mode == self.mode_play:
675 if key == self.keys['switch_to_chat']:
676 self.switch_mode('chat')
677 elif key == self.keys['switch_to_study']:
678 self.switch_mode('study')
679 elif key == self.keys['switch_to_password']:
680 self.switch_mode('password')
681 if key == self.keys['switch_to_edit'] and\
682 'WRITE' in self.game.tasks:
683 self.switch_mode('edit')
684 elif key == self.keys['flatten'] and\
685 'FLATTEN_SURROUNDINGS' in self.game.tasks:
686 self.send('TASK:FLATTEN_SURROUNDINGS ' + quote(self.password))
687 elif key in self.movement_keys and 'MOVE' in self.game.tasks:
688 self.send('TASK:MOVE ' + self.movement_keys[key])
689 elif self.mode == self.mode_edit:
690 self.send('TASK:WRITE %s %s' % (key, quote(self.password)))
691 self.switch_mode('play')
693 TUI('localhost:5000')