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 terrain 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. Hit Return to leave.', has_input_prompt=True, shows_info=True)
216 self.mode_portal = self.Mode('portal', 'This mode imprints/edits/removes a teleportation target on the ground you are currently standing on. 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 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 switch_mode(self, mode_name, keep_position = False):
283 self.map_mode = 'terrain'
284 self.mode = getattr(self, 'mode_' + mode_name)
285 if self.mode.shows_info and not keep_position:
286 player = self.game.get_thing(self.game.player_id, False)
287 self.explorer = YX(player.position.y, player.position.x)
288 if self.mode.name == 'waiting_for_server':
289 self.log_msg('@ waiting for server …')
290 if self.mode.name == 'edit':
291 self.show_help = True
292 elif self.mode.name == 'login':
294 self.send('LOGIN ' + quote(self.login_name))
296 self.log_msg('@ enter username')
297 elif self.mode.name == 'teleport':
298 self.log_msg("@ May teleport to %s" % (self.teleport_target_host)),
299 self.log_msg("@ Enter 'YES!' to enthusiastically affirm.");
300 elif self.mode.name == 'annotate' and self.explorer in self.game.info_db:
301 info = self.game.info_db[self.explorer]
304 elif self.mode.name == 'portal' and self.explorer in self.game.portals:
305 self.input_ = self.game.portals[self.explorer]
306 elif self.mode.name == 'password':
307 self.input_ = self.password
309 def loop(self, stdscr):
313 def safe_addstr(y, x, line):
314 if y < self.size.y - 1 or x + len(line) < self.size.x:
315 stdscr.addstr(y, x, line)
316 else: # workaround to <https://stackoverflow.com/q/7063128>
317 cut_i = self.size.x - x - 1
319 last_char = line[cut_i]
320 stdscr.addstr(y, self.size.x - 2, last_char)
321 stdscr.insstr(y, self.size.x - 2, ' ')
322 stdscr.addstr(y, x, cut)
326 def handle_recv(msg):
332 socket_client_class = PlomSocketClient
333 if self.host.startswith('ws://') or self.host.startswith('wss://'):
334 socket_client_class = WebSocketClient
337 self.socket = socket_client_class(handle_recv, self.host)
338 self.socket_thread = threading.Thread(target=self.socket.run)
339 self.socket_thread.start()
340 self.socket.send('TASKS')
341 self.switch_mode('login')
343 except ConnectionRefusedError:
344 self.log_msg('@ server connect failure, trying again …')
351 time.sleep(0.1) # FIXME necessitated by some some strange SSL race
352 # conditions with ws4py, find out what exactly
353 self.switch_mode('waiting_for_server')
356 def handle_input(msg):
357 command, args = self.parser.parse(msg)
360 def msg_into_lines_of_width(msg, width):
364 for i in range(len(msg)):
365 if x >= width or msg[i] == "\n":
375 def reset_screen_size():
376 self.size = YX(*stdscr.getmaxyx())
377 self.size = self.size - YX(self.size.y % 4, 0)
378 self.size = self.size - YX(0, self.size.x % 4)
379 self.window_width = int(self.size.x / 2)
381 def recalc_input_lines():
382 if not self.mode.has_input_prompt:
383 self.input_lines = []
385 self.input_lines = msg_into_lines_of_width(input_prompt + self.input_,
388 def move_explorer(direction):
389 target = self.game.map_geometry.move(self.explorer, direction)
391 self.explorer = target
398 for line in self.log:
399 lines += msg_into_lines_of_width(line, self.window_width)
402 max_y = self.size.y - len(self.input_lines)
403 for i in range(len(lines)):
404 if (i >= max_y - height_header):
406 safe_addstr(max_y - i - 1, self.window_width, lines[i])
409 if not self.game.turn_complete:
411 pos_i = self.explorer.y * self.game.map_geometry.size.x + self.explorer.x
412 info = 'TERRAIN: %s\n' % self.game.map_content[pos_i]
413 for t in self.game.things:
414 if t.position == self.explorer:
415 info += 'PLAYER @: %s\n' % t.name
416 if self.explorer in self.game.portals:
417 info += 'PORTAL: ' + self.game.portals[self.explorer] + '\n'
419 info += 'PORTAL: (none)\n'
420 if self.explorer in self.game.info_db:
421 info += 'ANNOTATION: ' + self.game.info_db[self.explorer]
423 info += 'ANNOTATION: waiting …'
424 lines = msg_into_lines_of_width(info, self.window_width)
426 for i in range(len(lines)):
427 y = height_header + i
428 if y >= self.size.y - len(self.input_lines):
430 safe_addstr(y, self.window_width, lines[i])
433 y = self.size.y - len(self.input_lines)
434 for i in range(len(self.input_lines)):
435 safe_addstr(y, self.window_width, self.input_lines[i])
439 if not self.game.turn_complete:
441 safe_addstr(0, self.window_width, 'TURN: ' + str(self.game.turn))
444 help = "hit [%s] for help" % self.keys['help']
445 if self.mode.has_input_prompt:
446 help = "enter /help for help"
447 safe_addstr(1, self.window_width, 'MODE: %s – %s' % (self.mode.name, help))
450 if not self.game.turn_complete:
453 map_content = self.game.map_content
454 if self.map_mode == 'control':
455 map_content = self.game.map_control_content
456 for y in range(self.game.map_geometry.size.y):
457 start = self.game.map_geometry.size.x * y
458 end = start + self.game.map_geometry.size.x
459 map_lines_split += [list(map_content[start:end])]
460 if self.map_mode == 'terrain':
461 for t in self.game.things:
462 map_lines_split[t.position.y][t.position.x] = '@'
463 if self.mode.shows_info:
464 map_lines_split[self.explorer.y][self.explorer.x] = '?'
466 if type(self.game.map_geometry) == MapGeometryHex:
468 for line in map_lines_split:
469 map_lines += [indent*' ' + ' '.join(line)]
470 indent = 0 if indent else 1
472 for line in map_lines_split:
473 map_lines += [' '.join(line)]
474 window_center = YX(int(self.size.y / 2),
475 int(self.window_width / 2))
476 player = self.game.get_thing(self.game.player_id, False)
477 center = player.position
478 if self.mode.shows_info:
479 center = self.explorer
480 center = YX(center.y, center.x * 2)
481 offset = center - window_center
482 if type(self.game.map_geometry) == MapGeometryHex and offset.y % 2:
484 term_y = max(0, -offset.y)
485 term_x = max(0, -offset.x)
486 map_y = max(0, offset.y)
487 map_x = max(0, offset.x)
488 while (term_y < self.size.y and map_y < self.game.map_geometry.size.y):
489 to_draw = map_lines[map_y][map_x:self.window_width + offset.x]
490 safe_addstr(term_y, term_x, to_draw)
495 content = "%s mode help (hit any key to disappear)\n\n%s\n\n" % (self.mode.name,
496 self.mode.help_intro)
497 if self.mode == self.mode_play:
498 content += "Available actions:\n"
499 if 'MOVE' in self.game.tasks:
500 content += "[%s] – move player\n" % ','.join(self.movement_keys)
501 if 'FLATTEN_SURROUNDINGS' in self.game.tasks:
502 content += "[%s] – flatten player's surroundings\n" % self.keys['flatten']
503 content += 'Other modes available from here:\n'
504 content += '[%s] – terrain edit mode\n' % self.keys['switch_to_edit']
505 content += '[%s] – terrain password edit mode\n' % self.keys['switch_to_password']
506 content += '[%s] – chat mode\n' % self.keys['switch_to_chat']
507 content += '[%s] – study mode\n' % self.keys['switch_to_study']
508 elif self.mode == self.mode_study:
509 content += 'Available actions:\n'
510 content += '[%s] – move question mark\n' % ','.join(self.movement_keys)
511 content += '[%s] – toggle view between terrain, and password protection areas\n' % self.keys['toggle_map_mode']
512 content += '\n\nOther modes available from here:'
513 content += '[%s] – chat mode\n' % self.keys['switch_to_chat']
514 content += '[%s] – play mode\n' % self.keys['switch_to_play']
515 content += '[%s] – portal mode\n' % self.keys['switch_to_portal']
516 content += '[%s] – annotation mode\n' % self.keys['switch_to_annotate']
517 elif self.mode == self.mode_chat:
518 content += '/nick NAME – re-name yourself to NAME\n'
519 content += '/msg USER TEXT – send TEXT to USER\n'
520 content += '/%s or /play – switch to play mode\n' % self.keys['switch_to_play']
521 content += '/%s or /study – switch to study mode\n' % self.keys['switch_to_study']
522 for i in range(self.size.y):
524 self.window_width * (not self.mode.has_input_prompt),
525 ' '*self.window_width)
527 for line in content.split('\n'):
528 lines += msg_into_lines_of_width(line, self.window_width)
529 for i in range(len(lines)):
533 self.window_width * (not self.mode.has_input_prompt),
538 if self.mode.has_input_prompt:
541 if self.mode.shows_info:
546 if not self.mode.is_intro:
552 curses.curs_set(False) # hide cursor
553 curses.use_default_colors();
556 self.explorer = YX(0, 0)
560 last_ping = datetime.datetime.now()
561 interval = datetime.timedelta(seconds=30)
563 now = datetime.datetime.now()
564 if now - last_ping > interval:
569 self.do_refresh = False
572 msg = self.queue.get(block=False)
577 key = stdscr.getkey()
578 self.do_refresh = True
581 self.show_help = False
582 if key == 'KEY_RESIZE':
584 elif self.mode.has_input_prompt and key == 'KEY_BACKSPACE':
585 self.input_ = self.input_[:-1]
586 elif self.mode.has_input_prompt and key == '\n' and self.input_ == '/help':
587 self.show_help = True
589 elif self.mode.has_input_prompt and key != '\n': # Return key
591 max_length = self.window_width * self.size.y - len(input_prompt) - 1
592 if len(self.input_) > max_length:
593 self.input_ = self.input_[:max_length]
594 elif key == self.keys['help'] and self.mode != self.mode_edit:
595 self.show_help = True
596 elif self.mode == self.mode_login and key == '\n':
597 self.login_name = self.input_
598 self.send('LOGIN ' + quote(self.input_))
600 elif self.mode == self.mode_password and key == '\n':
601 if self.input_ == '':
603 self.password = self.input_
605 self.switch_mode('play')
606 elif self.mode == self.mode_chat and key == '\n':
607 if self.input_[0] == '/':
608 if self.input_ in {'/' + self.keys['switch_to_play'], '/play'}:
609 self.switch_mode('play')
610 elif self.input_ in {'/' + self.keys['switch_to_study'], '/study'}:
611 self.switch_mode('study')
612 elif self.input_ == '/reconnect':
614 elif self.input_.startswith('/nick'):
615 tokens = self.input_.split(maxsplit=1)
617 self.send('NICK ' + quote(tokens[1]))
619 self.log_msg('? need login name')
620 elif self.input_.startswith('/msg'):
621 tokens = self.input_.split(maxsplit=2)
623 self.send('QUERY %s %s' % (quote(tokens[1]),
626 self.log_msg('? need message target and message')
628 self.log_msg('? unknown command')
630 self.send('ALL ' + quote(self.input_))
632 elif self.mode == self.mode_annotate and key == '\n':
633 if self.input_ == '':
635 self.send('ANNOTATE %s %s' % (self.explorer, quote(self.input_)))
637 self.switch_mode('study', keep_position=True)
638 elif self.mode == self.mode_portal and key == '\n':
639 if self.input_ == '':
641 self.send('PORTAL %s %s' % (self.explorer, quote(self.input_)))
643 self.switch_mode('study', keep_position=True)
644 elif self.mode == self.mode_teleport and key == '\n':
645 if self.input_ == 'YES!':
646 self.host = self.teleport_target_host
649 self.log_msg('@ teleport aborted')
650 self.switch_mode('play')
652 elif self.mode == self.mode_study:
653 if key == self.keys['switch_to_chat']:
654 self.switch_mode('chat')
655 elif key == self.keys['switch_to_play']:
656 self.switch_mode('play')
657 elif key == self.keys['switch_to_annotate']:
658 self.switch_mode('annotate', keep_position=True)
659 elif key == self.keys['switch_to_portal']:
660 self.switch_mode('portal', keep_position=True)
661 elif key == self.keys['toggle_map_mode']:
662 if self.map_mode == 'terrain':
663 self.map_mode = 'control'
665 self.map_mode = 'terrain'
666 elif key in self.movement_keys:
667 move_explorer(self.movement_keys[key])
668 elif self.mode == self.mode_play:
669 if key == self.keys['switch_to_chat']:
670 self.switch_mode('chat')
671 elif key == self.keys['switch_to_study']:
672 self.switch_mode('study')
673 elif key == self.keys['switch_to_password']:
674 self.switch_mode('password')
675 if key == self.keys['switch_to_edit'] and\
676 'WRITE' in self.game.tasks:
677 self.switch_mode('edit')
678 elif key == self.keys['flatten'] and\
679 'FLATTEN_SURROUNDINGS' in self.game.tasks:
680 self.send('TASK:FLATTEN_SURROUNDINGS ' + quote(self.password))
681 elif key in self.movement_keys and 'MOVE' in self.game.tasks:
682 self.send('TASK:MOVE ' + self.movement_keys[key])
683 elif self.mode == self.mode_edit:
684 self.send('TASK:WRITE %s %s' % (key, quote(self.password)))
685 self.switch_mode('play')
687 TUI('localhost:5000')