6 from plomrogue.game import GameBase
7 from plomrogue.parser import Parser
8 from plomrogue.mapping import YX, MapGeometrySquare, MapGeometryHex
9 from plomrogue.things import ThingBase
10 from plomrogue.misc import quote
11 from plomrogue.errors import BrokenSocketConnection
13 from ws4py.client import WebSocketBaseClient
14 class WebSocketClient(WebSocketBaseClient):
16 def __init__(self, recv_handler, *args, **kwargs):
17 super().__init__(*args, **kwargs)
18 self.recv_handler = recv_handler
21 def received_message(self, message):
23 message = str(message)
24 self.recv_handler(message)
27 def plom_closed(self):
28 return self.client_terminated
30 from plomrogue.io_tcp import PlomSocket
31 class PlomSocketClient(PlomSocket):
33 def __init__(self, recv_handler, url):
35 self.recv_handler = recv_handler
36 host, port = url.split(':')
37 super().__init__(socket.create_connection((host, port)))
45 for msg in self.recv():
47 self.socket = ssl.wrap_socket(self.socket)
49 self.recv_handler(msg)
50 except BrokenSocketConnection:
51 pass # we assume socket will be known as dead by now
53 def cmd_TURN(game, n):
57 game.turn_complete = False
58 cmd_TURN.argtypes = 'int:nonneg'
60 def cmd_LOGIN_OK(game):
61 game.tui.switch_mode('post_login_wait')
62 game.tui.send('GET_GAMESTATE')
63 game.tui.log_msg('@ welcome')
64 cmd_LOGIN_OK.argtypes = ''
66 def cmd_CHAT(game, msg):
67 game.tui.log_msg('# ' + msg)
68 game.tui.do_refresh = True
69 cmd_CHAT.argtypes = 'string'
71 def cmd_PLAYER_ID(game, player_id):
72 game.player_id = player_id
73 cmd_PLAYER_ID.argtypes = 'int:nonneg'
75 def cmd_THING_POS(game, thing_id, position):
76 t = game.get_thing(thing_id, True)
78 cmd_THING_POS.argtypes = 'int:nonneg yx_tuple:nonneg'
80 def cmd_THING_NAME(game, thing_id, name):
81 t = game.get_thing(thing_id, True)
83 cmd_THING_NAME.argtypes = 'int:nonneg string'
85 def cmd_MAP(game, geometry, size, content):
86 map_geometry_class = globals()['MapGeometry' + geometry]
87 game.map_geometry = map_geometry_class(size)
88 game.map_content = content
89 if type(game.map_geometry) == MapGeometrySquare:
90 game.tui.movement_keys = {
91 game.tui.keys['square_move_up']: 'UP',
92 game.tui.keys['square_move_left']: 'LEFT',
93 game.tui.keys['square_move_down']: 'DOWN',
94 game.tui.keys['square_move_right']: 'RIGHT',
96 elif type(game.map_geometry) == MapGeometryHex:
97 game.tui.movement_keys = {
98 game.tui.keys['hex_move_upleft']: 'UPLEFT',
99 game.tui.keys['hex_move_upright']: 'UPRIGHT',
100 game.tui.keys['hex_move_right']: 'RIGHT',
101 game.tui.keys['hex_move_downright']: 'DOWNRIGHT',
102 game.tui.keys['hex_move_downleft']: 'DOWNLEFT',
103 game.tui.keys['hex_move_left']: 'LEFT',
105 cmd_MAP.argtypes = 'string:map_geometry yx_tuple:pos string'
107 def cmd_MAP_CONTROL(game, content):
108 game.map_control_content = content
109 cmd_MAP_CONTROL.argtypes = 'string'
111 def cmd_GAME_STATE_COMPLETE(game):
113 if game.tui.mode.name == 'post_login_wait':
114 game.tui.switch_mode('play')
115 if game.tui.mode.shows_info:
116 game.tui.query_info()
117 player = game.get_thing(game.player_id, False)
118 if player.position in game.portals:
119 game.tui.teleport_target_host = game.portals[player.position]
120 game.tui.switch_mode('teleport')
121 game.turn_complete = True
122 game.tui.do_refresh = True
123 cmd_GAME_STATE_COMPLETE.argtypes = ''
125 def cmd_PORTAL(game, position, msg):
126 game.portals[position] = msg
127 cmd_PORTAL.argtypes = 'yx_tuple:nonneg string'
129 def cmd_PLAY_ERROR(game, msg):
131 game.tui.do_refresh = True
132 cmd_PLAY_ERROR.argtypes = 'string'
134 def cmd_GAME_ERROR(game, msg):
135 game.tui.log_msg('? game error: ' + msg)
136 game.tui.do_refresh = True
137 cmd_GAME_ERROR.argtypes = 'string'
139 def cmd_ARGUMENT_ERROR(game, msg):
140 game.tui.log_msg('? syntax error: ' + msg)
141 game.tui.do_refresh = True
142 cmd_ARGUMENT_ERROR.argtypes = 'string'
144 def cmd_ANNOTATION(game, position, msg):
145 game.info_db[position] = msg
146 if game.tui.mode.shows_info:
147 game.tui.do_refresh = True
148 cmd_ANNOTATION.argtypes = 'yx_tuple:nonneg string'
150 def cmd_TASKS(game, tasks_comma_separated):
151 game.tasks = tasks_comma_separated.split(',')
152 cmd_TASKS.argtypes = 'string'
156 cmd_PONG.argtypes = ''
158 class Game(GameBase):
159 thing_type = ThingBase
160 turn_complete = False
163 def __init__(self, *args, **kwargs):
164 super().__init__(*args, **kwargs)
165 self.register_command(cmd_LOGIN_OK)
166 self.register_command(cmd_PONG)
167 self.register_command(cmd_CHAT)
168 self.register_command(cmd_PLAYER_ID)
169 self.register_command(cmd_TURN)
170 self.register_command(cmd_THING_POS)
171 self.register_command(cmd_THING_NAME)
172 self.register_command(cmd_MAP)
173 self.register_command(cmd_MAP_CONTROL)
174 self.register_command(cmd_PORTAL)
175 self.register_command(cmd_ANNOTATION)
176 self.register_command(cmd_GAME_STATE_COMPLETE)
177 self.register_command(cmd_ARGUMENT_ERROR)
178 self.register_command(cmd_GAME_ERROR)
179 self.register_command(cmd_PLAY_ERROR)
180 self.register_command(cmd_TASKS)
181 self.map_content = ''
186 def get_string_options(self, string_option_type):
187 if string_option_type == 'map_geometry':
188 return ['Hex', 'Square']
191 def get_command(self, command_name):
192 from functools import partial
193 f = partial(self.commands[command_name], self)
194 f.argtypes = self.commands[command_name].argtypes
201 def __init__(self, name, help_intro, has_input_prompt=False,
202 shows_info=False, is_intro = False):
204 self.has_input_prompt = has_input_prompt
205 self.shows_info = shows_info
206 self.is_intro = is_intro
207 self.help_intro = help_intro
209 def __init__(self, host):
213 self.mode_play = self.Mode('play', 'This mode allows you to interact with the map.')
214 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.', shows_info=True)
215 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.')
216 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)
217 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)
218 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)
219 self.mode_waiting_for_server = self.Mode('waiting_for_server', 'Waiting for a server response.', is_intro=True)
220 self.mode_login = self.Mode('login', 'Pick your player name.', has_input_prompt=True, is_intro=True)
221 self.mode_post_login_wait = self.Mode('post_login_wait', 'Waiting for a server response.', is_intro=True)
222 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)
223 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)
226 self.parser = Parser(self.game)
228 self.do_refresh = True
229 self.queue = queue.Queue()
230 self.login_name = None
231 self.map_mode = 'terrain'
232 self.password = 'foo'
233 self.switch_mode('waiting_for_server')
235 'switch_to_chat': 't',
236 'switch_to_play': 'p',
237 'switch_to_password': 'P',
238 'switch_to_annotate': 'M',
239 'switch_to_portal': 'T',
240 'switch_to_study': '?',
241 'switch_to_edit': 'm',
243 'toggle_map_mode': 'M',
244 'hex_move_upleft': 'w',
245 'hex_move_upright': 'e',
246 'hex_move_right': 'd',
247 'hex_move_downright': 'x',
248 'hex_move_downleft': 'y',
249 'hex_move_left': 'a',
250 'square_move_up': 'w',
251 'square_move_left': 'a',
252 'square_move_down': 's',
253 'square_move_right': 'd',
255 if os.path.isfile('config.json'):
256 with open('config.json', 'r') as f:
257 keys_conf = json.loads(f.read())
259 self.keys[k] = keys_conf[k]
260 self.show_help = False
261 self.disconnected = True
262 self.force_instant_connect = True
263 self.input_lines = []
264 curses.wrapper(self.loop)
271 def handle_recv(msg):
277 self.log_msg('@ attempting connect')
278 socket_client_class = PlomSocketClient
279 if self.host.startswith('ws://') or self.host.startswith('wss://'):
280 socket_client_class = WebSocketClient
282 self.socket = socket_client_class(handle_recv, self.host)
283 self.socket_thread = threading.Thread(target=self.socket.run)
284 self.socket_thread.start()
285 self.disconnected = False
286 self.socket.send('TASKS')
287 self.switch_mode('login')
288 except ConnectionRefusedError:
289 self.log_msg('@ server connect failure')
290 self.disconnected = True
291 self.switch_mode('waiting_for_server')
292 self.do_refresh = True
295 self.log_msg('@ attempting reconnect')
297 time.sleep(0.1) # FIXME necessitated by some some strange SSL race
298 # conditions with ws4py, find out what exactly
299 self.switch_mode('waiting_for_server')
304 if hasattr(self.socket, 'plom_closed') and self.socket.plom_closed:
305 raise BrokenSocketConnection
306 self.socket.send(msg)
307 except (BrokenPipeError, BrokenSocketConnection):
308 self.log_msg('@ server disconnected :(')
309 self.disconnected = True
310 self.force_instant_connect = True
311 self.do_refresh = True
313 def log_msg(self, msg):
315 if len(self.log) > 100:
316 self.log = self.log[-100:]
318 def query_info(self):
319 self.send('GET_ANNOTATION ' + str(self.explorer))
321 def restore_input_values(self):
322 if self.mode.name == 'annotate' and self.explorer in self.game.info_db:
323 info = self.game.info_db[self.explorer]
326 elif self.mode.name == 'portal' and self.explorer in self.game.portals:
327 self.input_ = self.game.portals[self.explorer]
328 elif self.mode.name == 'password':
329 self.input_ = self.password
331 def switch_mode(self, mode_name):
332 self.map_mode = 'terrain'
333 self.mode = getattr(self, 'mode_' + mode_name)
334 if self.mode.shows_info:
335 player = self.game.get_thing(self.game.player_id, False)
336 self.explorer = YX(player.position.y, player.position.x)
337 if self.mode.name == 'waiting_for_server':
338 self.log_msg('@ waiting for server …')
339 if self.mode.name == 'edit':
340 self.show_help = True
341 elif self.mode.name == 'login':
343 self.send('LOGIN ' + quote(self.login_name))
345 self.log_msg('@ enter username')
346 elif self.mode.name == 'teleport':
347 self.log_msg("@ May teleport to %s" % (self.teleport_target_host)),
348 self.log_msg("@ Enter 'YES!' to enthusiastically affirm.");
349 self.restore_input_values()
351 def loop(self, stdscr):
354 def safe_addstr(y, x, line):
355 if y < self.size.y - 1 or x + len(line) < self.size.x:
356 stdscr.addstr(y, x, line)
357 else: # workaround to <https://stackoverflow.com/q/7063128>
358 cut_i = self.size.x - x - 1
360 last_char = line[cut_i]
361 stdscr.addstr(y, self.size.x - 2, last_char)
362 stdscr.insstr(y, self.size.x - 2, ' ')
363 stdscr.addstr(y, x, cut)
365 def handle_input(msg):
366 command, args = self.parser.parse(msg)
369 def msg_into_lines_of_width(msg, width):
373 for i in range(len(msg)):
374 if x >= width or msg[i] == "\n":
384 def reset_screen_size():
385 self.size = YX(*stdscr.getmaxyx())
386 self.size = self.size - YX(self.size.y % 4, 0)
387 self.size = self.size - YX(0, self.size.x % 4)
388 self.window_width = int(self.size.x / 2)
390 def recalc_input_lines():
391 if not self.mode.has_input_prompt:
392 self.input_lines = []
394 self.input_lines = msg_into_lines_of_width(input_prompt + self.input_,
397 def move_explorer(direction):
398 target = self.game.map_geometry.move(self.explorer, direction)
400 self.explorer = target
407 for line in self.log:
408 lines += msg_into_lines_of_width(line, self.window_width)
411 max_y = self.size.y - len(self.input_lines)
412 for i in range(len(lines)):
413 if (i >= max_y - height_header):
415 safe_addstr(max_y - i - 1, self.window_width, lines[i])
418 if not self.game.turn_complete:
420 pos_i = self.explorer.y * self.game.map_geometry.size.x + self.explorer.x
421 info = 'TERRAIN: %s\n' % self.game.map_content[pos_i]
422 for t in self.game.things:
423 if t.position == self.explorer:
424 info += 'PLAYER @: %s\n' % t.name
425 if self.explorer in self.game.portals:
426 info += 'PORTAL: ' + self.game.portals[self.explorer] + '\n'
428 info += 'PORTAL: (none)\n'
429 if self.explorer in self.game.info_db:
430 info += 'ANNOTATION: ' + self.game.info_db[self.explorer]
432 info += 'ANNOTATION: waiting …'
433 lines = msg_into_lines_of_width(info, self.window_width)
435 for i in range(len(lines)):
436 y = height_header + i
437 if y >= self.size.y - len(self.input_lines):
439 safe_addstr(y, self.window_width, lines[i])
442 y = self.size.y - len(self.input_lines)
443 for i in range(len(self.input_lines)):
444 safe_addstr(y, self.window_width, self.input_lines[i])
448 if not self.game.turn_complete:
450 safe_addstr(0, self.window_width, 'TURN: ' + str(self.game.turn))
453 help = "hit [%s] for help" % self.keys['help']
454 if self.mode.has_input_prompt:
455 help = "enter /help for help"
456 safe_addstr(1, self.window_width, 'MODE: %s – %s' % (self.mode.name, help))
459 if not self.game.turn_complete:
462 map_content = self.game.map_content
463 if self.map_mode == 'control':
464 map_content = self.game.map_control_content
465 for y in range(self.game.map_geometry.size.y):
466 start = self.game.map_geometry.size.x * y
467 end = start + self.game.map_geometry.size.x
468 map_lines_split += [list(map_content[start:end])]
469 if self.map_mode == 'terrain':
470 for t in self.game.things:
471 map_lines_split[t.position.y][t.position.x] = '@'
472 if self.mode.shows_info:
473 map_lines_split[self.explorer.y][self.explorer.x] = '?'
475 if type(self.game.map_geometry) == MapGeometryHex:
477 for line in map_lines_split:
478 map_lines += [indent*' ' + ' '.join(line)]
479 indent = 0 if indent else 1
481 for line in map_lines_split:
482 map_lines += [' '.join(line)]
483 window_center = YX(int(self.size.y / 2),
484 int(self.window_width / 2))
485 player = self.game.get_thing(self.game.player_id, False)
486 center = player.position
487 if self.mode.shows_info:
488 center = self.explorer
489 center = YX(center.y, center.x * 2)
490 offset = center - window_center
491 if type(self.game.map_geometry) == MapGeometryHex and offset.y % 2:
493 term_y = max(0, -offset.y)
494 term_x = max(0, -offset.x)
495 map_y = max(0, offset.y)
496 map_x = max(0, offset.x)
497 while (term_y < self.size.y and map_y < self.game.map_geometry.size.y):
498 to_draw = map_lines[map_y][map_x:self.window_width + offset.x]
499 safe_addstr(term_y, term_x, to_draw)
504 content = "%s mode help\n\n%s\n\n" % (self.mode.name,
505 self.mode.help_intro)
506 if self.mode == self.mode_play:
507 content += "Available actions:\n"
508 if 'MOVE' in self.game.tasks:
509 content += "[%s] – move player\n" % ','.join(self.movement_keys)
510 if 'FLATTEN_SURROUNDINGS' in self.game.tasks:
511 content += "[%s] – flatten player's surroundings\n" % self.keys['flatten']
512 content += 'Other modes available from here:\n'
513 content += '[%s] – chat mode\n' % self.keys['switch_to_chat']
514 content += '[%s] – study mode\n' % self.keys['switch_to_study']
515 content += '[%s] – terrain edit mode\n' % self.keys['switch_to_edit']
516 content += '[%s] – portal edit mode\n' % self.keys['switch_to_portal']
517 content += '[%s] – annotation mode\n' % self.keys['switch_to_annotate']
518 content += '[%s] – password input mode\n' % self.keys['switch_to_password']
519 elif self.mode == self.mode_study:
520 content += 'Available actions:\n'
521 content += '[%s] – move question mark\n' % ','.join(self.movement_keys)
522 content += '[%s] – toggle view between terrain, and password protection areas\n' % self.keys['toggle_map_mode']
523 content += '\n\nOther modes available from here:'
524 content += '[%s] – chat mode\n' % self.keys['switch_to_chat']
525 content += '[%s] – play mode\n' % self.keys['switch_to_play']
526 elif self.mode == self.mode_chat:
527 content += '/nick NAME – re-name yourself to NAME\n'
528 content += '/msg USER TEXT – send TEXT to USER\n'
529 content += '/%s or /play – switch to play mode\n' % self.keys['switch_to_play']
530 content += '/%s or /study – switch to study mode\n' % self.keys['switch_to_study']
531 for i in range(self.size.y):
533 self.window_width * (not self.mode.has_input_prompt),
534 ' '*self.window_width)
536 for line in content.split('\n'):
537 lines += msg_into_lines_of_width(line, self.window_width)
538 for i in range(len(lines)):
542 self.window_width * (not self.mode.has_input_prompt),
547 if self.mode.has_input_prompt:
550 if self.mode.shows_info:
555 if not self.mode.is_intro:
561 curses.curs_set(False) # hide cursor
562 curses.use_default_colors();
565 self.explorer = YX(0, 0)
568 interval = datetime.timedelta(seconds=5)
569 last_ping = datetime.datetime.now() - interval
571 if self.disconnected and self.force_instant_connect:
572 self.force_instant_connect = False
574 now = datetime.datetime.now()
575 if now - last_ping > interval:
576 if self.disconnected:
583 self.do_refresh = False
586 msg = self.queue.get(block=False)
591 key = stdscr.getkey()
592 self.do_refresh = True
595 self.show_help = False
596 if key == 'KEY_RESIZE':
598 elif self.mode.has_input_prompt and key == 'KEY_BACKSPACE':
599 self.input_ = self.input_[:-1]
600 elif self.mode.has_input_prompt and key == '\n' and self.input_ == '/help':
601 self.show_help = True
603 self.restore_input_values()
604 elif self.mode.has_input_prompt and key != '\n': # Return key
606 max_length = self.window_width * self.size.y - len(input_prompt) - 1
607 if len(self.input_) > max_length:
608 self.input_ = self.input_[:max_length]
609 elif key == self.keys['help'] and self.mode != self.mode_edit:
610 self.show_help = True
611 elif self.mode == self.mode_login and key == '\n':
612 self.login_name = self.input_
613 self.send('LOGIN ' + quote(self.input_))
615 elif self.mode == self.mode_password and key == '\n':
616 if self.input_ == '':
618 self.password = self.input_
620 self.switch_mode('play')
621 elif self.mode == self.mode_chat and key == '\n':
622 if self.input_[0] == '/':
623 if self.input_ in {'/' + self.keys['switch_to_play'], '/play'}:
624 self.switch_mode('play')
625 elif self.input_ in {'/' + self.keys['switch_to_study'], '/study'}:
626 self.switch_mode('study')
627 elif self.input_.startswith('/nick'):
628 tokens = self.input_.split(maxsplit=1)
630 self.send('NICK ' + quote(tokens[1]))
632 self.log_msg('? need login name')
633 elif self.input_.startswith('/msg'):
634 tokens = self.input_.split(maxsplit=2)
636 self.send('QUERY %s %s' % (quote(tokens[1]),
639 self.log_msg('? need message target and message')
641 self.log_msg('? unknown command')
643 self.send('ALL ' + quote(self.input_))
645 elif self.mode == self.mode_annotate and key == '\n':
646 if self.input_ == '':
648 self.send('ANNOTATE %s %s %s' % (self.explorer, quote(self.input_),
649 quote(self.password)))
651 self.switch_mode('play')
652 elif self.mode == self.mode_portal and key == '\n':
653 if self.input_ == '':
655 self.send('PORTAL %s %s %s' % (self.explorer, quote(self.input_),
656 quote(self.password)))
658 self.switch_mode('play')
659 elif self.mode == self.mode_teleport and key == '\n':
660 if self.input_ == 'YES!':
661 self.host = self.teleport_target_host
664 self.log_msg('@ teleport aborted')
665 self.switch_mode('play')
667 elif self.mode == self.mode_study:
668 if key == self.keys['switch_to_chat']:
669 self.switch_mode('chat')
670 elif key == self.keys['switch_to_play']:
671 self.switch_mode('play')
672 elif key == self.keys['toggle_map_mode']:
673 if self.map_mode == 'terrain':
674 self.map_mode = 'control'
676 self.map_mode = 'terrain'
677 elif key in self.movement_keys:
678 move_explorer(self.movement_keys[key])
679 elif self.mode == self.mode_play:
680 if key == self.keys['switch_to_chat']:
681 self.switch_mode('chat')
682 elif key == self.keys['switch_to_study']:
683 self.switch_mode('study')
684 elif key == self.keys['switch_to_annotate']:
685 self.switch_mode('annotate')
686 elif key == self.keys['switch_to_portal']:
687 self.switch_mode('portal')
688 elif key == self.keys['switch_to_password']:
689 self.switch_mode('password')
690 if key == self.keys['switch_to_edit'] and\
691 'WRITE' in self.game.tasks:
692 self.switch_mode('edit')
693 elif key == self.keys['flatten'] and\
694 'FLATTEN_SURROUNDINGS' in self.game.tasks:
695 self.send('TASK:FLATTEN_SURROUNDINGS ' + quote(self.password))
696 elif key in self.movement_keys and 'MOVE' in self.game.tasks:
697 self.send('TASK:MOVE ' + self.movement_keys[key])
698 elif self.mode == self.mode_edit:
699 self.send('TASK:WRITE %s %s' % (key, quote(self.password)))
700 self.switch_mode('play')
702 TUI('localhost:5000')