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_FOV(game, content):
109 cmd_FOV.argtypes = 'string'
111 def cmd_MAP_CONTROL(game, content):
112 game.map_control_content = content
113 cmd_MAP_CONTROL.argtypes = 'string'
115 def cmd_GAME_STATE_COMPLETE(game):
117 if game.tui.mode.name == 'post_login_wait':
118 game.tui.switch_mode('play')
119 if game.tui.mode.shows_info:
120 game.tui.query_info()
121 player = game.get_thing(game.player_id, False)
122 if player.position in game.portals:
123 game.tui.teleport_target_host = game.portals[player.position]
124 game.tui.switch_mode('teleport')
125 game.turn_complete = True
126 game.tui.do_refresh = True
127 cmd_GAME_STATE_COMPLETE.argtypes = ''
129 def cmd_PORTAL(game, position, msg):
130 game.portals[position] = msg
131 cmd_PORTAL.argtypes = 'yx_tuple:nonneg string'
133 def cmd_PLAY_ERROR(game, msg):
135 game.tui.do_refresh = True
136 cmd_PLAY_ERROR.argtypes = 'string'
138 def cmd_GAME_ERROR(game, msg):
139 game.tui.log_msg('? game error: ' + msg)
140 game.tui.do_refresh = True
141 cmd_GAME_ERROR.argtypes = 'string'
143 def cmd_ARGUMENT_ERROR(game, msg):
144 game.tui.log_msg('? syntax error: ' + msg)
145 game.tui.do_refresh = True
146 cmd_ARGUMENT_ERROR.argtypes = 'string'
148 def cmd_ANNOTATION(game, position, msg):
149 game.info_db[position] = msg
150 if game.tui.mode.shows_info:
151 game.tui.do_refresh = True
152 cmd_ANNOTATION.argtypes = 'yx_tuple:nonneg string'
154 def cmd_TASKS(game, tasks_comma_separated):
155 game.tasks = tasks_comma_separated.split(',')
156 cmd_TASKS.argtypes = 'string'
160 cmd_PONG.argtypes = ''
162 class Game(GameBase):
163 thing_type = ThingBase
164 turn_complete = False
167 def __init__(self, *args, **kwargs):
168 super().__init__(*args, **kwargs)
169 self.register_command(cmd_LOGIN_OK)
170 self.register_command(cmd_PONG)
171 self.register_command(cmd_CHAT)
172 self.register_command(cmd_PLAYER_ID)
173 self.register_command(cmd_TURN)
174 self.register_command(cmd_THING_POS)
175 self.register_command(cmd_THING_NAME)
176 self.register_command(cmd_MAP)
177 self.register_command(cmd_MAP_CONTROL)
178 self.register_command(cmd_PORTAL)
179 self.register_command(cmd_ANNOTATION)
180 self.register_command(cmd_GAME_STATE_COMPLETE)
181 self.register_command(cmd_ARGUMENT_ERROR)
182 self.register_command(cmd_GAME_ERROR)
183 self.register_command(cmd_PLAY_ERROR)
184 self.register_command(cmd_TASKS)
185 self.register_command(cmd_FOV)
186 self.map_content = ''
191 def get_string_options(self, string_option_type):
192 if string_option_type == 'map_geometry':
193 return ['Hex', 'Square']
196 def get_command(self, command_name):
197 from functools import partial
198 f = partial(self.commands[command_name], self)
199 f.argtypes = self.commands[command_name].argtypes
206 def __init__(self, name, help_intro, has_input_prompt=False,
207 shows_info=False, is_intro = False):
209 self.has_input_prompt = has_input_prompt
210 self.shows_info = shows_info
211 self.is_intro = is_intro
212 self.help_intro = help_intro
214 def __init__(self, host):
218 self.mode_play = self.Mode('play', 'This mode allows you to interact with the map.')
219 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)
220 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.')
221 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)
222 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)
223 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 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:', has_input_prompt=True)
224 self.mode_waiting_for_server = self.Mode('waiting_for_server', 'Waiting for a server response.', is_intro=True)
225 self.mode_login = self.Mode('login', 'Pick your player name.', has_input_prompt=True, is_intro=True)
226 self.mode_post_login_wait = self.Mode('post_login_wait', 'Waiting for a server response.', is_intro=True)
227 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)
228 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)
231 self.parser = Parser(self.game)
233 self.do_refresh = True
234 self.queue = queue.Queue()
235 self.login_name = None
236 self.map_mode = 'terrain'
237 self.password = 'foo'
238 self.switch_mode('waiting_for_server')
240 'switch_to_chat': 't',
241 'switch_to_play': 'p',
242 'switch_to_password': 'P',
243 'switch_to_annotate': 'M',
244 'switch_to_portal': 'T',
245 'switch_to_study': '?',
246 'switch_to_edit': 'm',
248 'toggle_map_mode': 'M',
249 'hex_move_upleft': 'w',
250 'hex_move_upright': 'e',
251 'hex_move_right': 'd',
252 'hex_move_downright': 'x',
253 'hex_move_downleft': 'y',
254 'hex_move_left': 'a',
255 'square_move_up': 'w',
256 'square_move_left': 'a',
257 'square_move_down': 's',
258 'square_move_right': 'd',
260 if os.path.isfile('config.json'):
261 with open('config.json', 'r') as f:
262 keys_conf = json.loads(f.read())
264 self.keys[k] = keys_conf[k]
265 self.show_help = False
266 self.disconnected = True
267 self.force_instant_connect = True
268 self.input_lines = []
270 curses.wrapper(self.loop)
277 def handle_recv(msg):
283 self.log_msg('@ attempting connect')
284 socket_client_class = PlomSocketClient
285 if self.host.startswith('ws://') or self.host.startswith('wss://'):
286 socket_client_class = WebSocketClient
288 self.socket = socket_client_class(handle_recv, self.host)
289 self.socket_thread = threading.Thread(target=self.socket.run)
290 self.socket_thread.start()
291 self.disconnected = False
292 self.socket.send('TASKS')
293 self.switch_mode('login')
294 except ConnectionRefusedError:
295 self.log_msg('@ server connect failure')
296 self.disconnected = True
297 self.switch_mode('waiting_for_server')
298 self.do_refresh = True
301 self.log_msg('@ attempting reconnect')
303 time.sleep(0.1) # FIXME necessitated by some some strange SSL race
304 # conditions with ws4py, find out what exactly
305 self.switch_mode('waiting_for_server')
310 if hasattr(self.socket, 'plom_closed') and self.socket.plom_closed:
311 raise BrokenSocketConnection
312 self.socket.send(msg)
313 except (BrokenPipeError, BrokenSocketConnection):
314 self.log_msg('@ server disconnected :(')
315 self.disconnected = True
316 self.force_instant_connect = True
317 self.do_refresh = True
319 def log_msg(self, msg):
321 if len(self.log) > 100:
322 self.log = self.log[-100:]
324 def query_info(self):
325 self.send('GET_ANNOTATION ' + str(self.explorer))
327 def restore_input_values(self):
328 if self.mode.name == 'annotate' and self.explorer in self.game.info_db:
329 info = self.game.info_db[self.explorer]
332 elif self.mode.name == 'portal' and self.explorer in self.game.portals:
333 self.input_ = self.game.portals[self.explorer]
334 elif self.mode.name == 'password':
335 self.input_ = self.password
337 def switch_mode(self, mode_name):
338 self.map_mode = 'terrain'
339 self.mode = getattr(self, 'mode_' + mode_name)
340 if self.mode.shows_info:
341 player = self.game.get_thing(self.game.player_id, False)
342 self.explorer = YX(player.position.y, player.position.x)
343 if self.mode.name == 'waiting_for_server':
344 self.log_msg('@ waiting for server …')
345 if self.mode.name == 'edit':
346 self.show_help = True
347 elif self.mode.name == 'login':
349 self.send('LOGIN ' + quote(self.login_name))
351 self.log_msg('@ enter username')
352 elif self.mode.name == 'teleport':
353 self.log_msg("@ May teleport to %s" % (self.teleport_target_host)),
354 self.log_msg("@ Enter 'YES!' to enthusiastically affirm.");
355 self.restore_input_values()
357 def loop(self, stdscr):
360 def safe_addstr(y, x, line):
361 if y < self.size.y - 1 or x + len(line) < self.size.x:
362 stdscr.addstr(y, x, line)
363 else: # workaround to <https://stackoverflow.com/q/7063128>
364 cut_i = self.size.x - x - 1
366 last_char = line[cut_i]
367 stdscr.addstr(y, self.size.x - 2, last_char)
368 stdscr.insstr(y, self.size.x - 2, ' ')
369 stdscr.addstr(y, x, cut)
371 def handle_input(msg):
372 command, args = self.parser.parse(msg)
375 def msg_into_lines_of_width(msg, width):
379 for i in range(len(msg)):
380 if x >= width or msg[i] == "\n":
390 def reset_screen_size():
391 self.size = YX(*stdscr.getmaxyx())
392 self.size = self.size - YX(self.size.y % 4, 0)
393 self.size = self.size - YX(0, self.size.x % 4)
394 self.window_width = int(self.size.x / 2)
396 def recalc_input_lines():
397 if not self.mode.has_input_prompt:
398 self.input_lines = []
400 self.input_lines = msg_into_lines_of_width(input_prompt + self.input_,
403 def move_explorer(direction):
404 target = self.game.map_geometry.move(self.explorer, direction)
406 self.explorer = target
413 for line in self.log:
414 lines += msg_into_lines_of_width(line, self.window_width)
417 max_y = self.size.y - len(self.input_lines)
418 for i in range(len(lines)):
419 if (i >= max_y - height_header):
421 safe_addstr(max_y - i - 1, self.window_width, lines[i])
424 if not self.game.turn_complete:
426 pos_i = self.explorer.y * self.game.map_geometry.size.x + self.explorer.x
427 info = 'outside field of view'
428 if self.game.fov[pos_i] == '.':
429 info = 'TERRAIN: %s\n' % self.game.map_content[pos_i]
430 for t in self.game.things:
431 if t.position == self.explorer:
432 info += 'PLAYER @: %s\n' % t.name
433 if self.explorer in self.game.portals:
434 info += 'PORTAL: ' + self.game.portals[self.explorer] + '\n'
436 info += 'PORTAL: (none)\n'
437 if self.explorer in self.game.info_db:
438 info += 'ANNOTATION: ' + self.game.info_db[self.explorer]
440 info += 'ANNOTATION: waiting …'
441 lines = msg_into_lines_of_width(info, self.window_width)
443 for i in range(len(lines)):
444 y = height_header + i
445 if y >= self.size.y - len(self.input_lines):
447 safe_addstr(y, self.window_width, lines[i])
450 y = self.size.y - len(self.input_lines)
451 for i in range(len(self.input_lines)):
452 safe_addstr(y, self.window_width, self.input_lines[i])
456 if not self.game.turn_complete:
458 safe_addstr(0, self.window_width, 'TURN: ' + str(self.game.turn))
461 help = "hit [%s] for help" % self.keys['help']
462 if self.mode.has_input_prompt:
463 help = "enter /help for help"
464 safe_addstr(1, self.window_width, 'MODE: %s – %s' % (self.mode.name, help))
467 if not self.game.turn_complete:
470 map_content = self.game.map_content
471 if self.map_mode == 'control':
472 map_content = self.game.map_control_content
473 for y in range(self.game.map_geometry.size.y):
474 start = self.game.map_geometry.size.x * y
475 end = start + self.game.map_geometry.size.x
476 map_lines_split += [list(map_content[start:end])]
477 if self.map_mode == 'terrain':
478 for t in self.game.things:
479 map_lines_split[t.position.y][t.position.x] = '@'
480 if self.mode.shows_info:
481 map_lines_split[self.explorer.y][self.explorer.x] = '?'
483 if type(self.game.map_geometry) == MapGeometryHex:
485 for line in map_lines_split:
486 map_lines += [indent*' ' + ' '.join(line)]
487 indent = 0 if indent else 1
489 for line in map_lines_split:
490 map_lines += [' '.join(line)]
491 window_center = YX(int(self.size.y / 2),
492 int(self.window_width / 2))
493 player = self.game.get_thing(self.game.player_id, False)
494 center = player.position
495 if self.mode.shows_info:
496 center = self.explorer
497 center = YX(center.y, center.x * 2)
498 offset = center - window_center
499 if type(self.game.map_geometry) == MapGeometryHex and offset.y % 2:
501 term_y = max(0, -offset.y)
502 term_x = max(0, -offset.x)
503 map_y = max(0, offset.y)
504 map_x = max(0, offset.x)
505 while (term_y < self.size.y and map_y < self.game.map_geometry.size.y):
506 to_draw = map_lines[map_y][map_x:self.window_width + offset.x]
507 safe_addstr(term_y, term_x, to_draw)
512 content = "%s mode help\n\n%s\n\n" % (self.mode.name,
513 self.mode.help_intro)
514 if self.mode == self.mode_play:
515 content += "Available actions:\n"
516 if 'MOVE' in self.game.tasks:
517 content += "[%s] – move player\n" % ','.join(self.movement_keys)
518 if 'FLATTEN_SURROUNDINGS' in self.game.tasks:
519 content += "[%s] – flatten player's surroundings\n" % self.keys['flatten']
520 content += 'Other modes available from here:\n'
521 content += '[%s] – chat mode\n' % self.keys['switch_to_chat']
522 content += '[%s] – study mode\n' % self.keys['switch_to_study']
523 content += '[%s] – terrain edit mode\n' % self.keys['switch_to_edit']
524 content += '[%s] – portal edit mode\n' % self.keys['switch_to_portal']
525 content += '[%s] – annotation mode\n' % self.keys['switch_to_annotate']
526 content += '[%s] – password input mode\n' % self.keys['switch_to_password']
527 elif self.mode == self.mode_study:
528 content += 'Available actions:\n'
529 content += '[%s] – move question mark\n' % ','.join(self.movement_keys)
530 content += '[%s] – toggle view between terrain, and password protection areas\n' % self.keys['toggle_map_mode']
531 content += '\n\nOther modes available from here:'
532 content += '[%s] – chat mode\n' % self.keys['switch_to_chat']
533 content += '[%s] – play mode\n' % self.keys['switch_to_play']
534 elif self.mode == self.mode_chat:
535 content += '/nick NAME – re-name yourself to NAME\n'
536 #content += '/msg USER TEXT – send TEXT to USER\n'
537 content += '/%s or /play – switch to play mode\n' % self.keys['switch_to_play']
538 content += '/%s or /study – switch to study mode\n' % self.keys['switch_to_study']
539 for i in range(self.size.y):
541 self.window_width * (not self.mode.has_input_prompt),
542 ' '*self.window_width)
544 for line in content.split('\n'):
545 lines += msg_into_lines_of_width(line, self.window_width)
546 for i in range(len(lines)):
550 self.window_width * (not self.mode.has_input_prompt),
555 if self.mode.has_input_prompt:
558 if self.mode.shows_info:
563 if not self.mode.is_intro:
569 curses.curs_set(False) # hide cursor
570 curses.use_default_colors();
573 self.explorer = YX(0, 0)
576 interval = datetime.timedelta(seconds=5)
577 last_ping = datetime.datetime.now() - interval
579 if self.disconnected and self.force_instant_connect:
580 self.force_instant_connect = False
582 now = datetime.datetime.now()
583 if now - last_ping > interval:
584 if self.disconnected:
591 self.do_refresh = False
594 msg = self.queue.get(block=False)
599 key = stdscr.getkey()
600 self.do_refresh = True
603 self.show_help = False
604 if key == 'KEY_RESIZE':
606 elif self.mode.has_input_prompt and key == 'KEY_BACKSPACE':
607 self.input_ = self.input_[:-1]
608 elif self.mode.has_input_prompt and key == '\n' and self.input_ == '/help':
609 self.show_help = True
611 self.restore_input_values()
612 elif self.mode.has_input_prompt and key != '\n': # Return key
614 max_length = self.window_width * self.size.y - len(input_prompt) - 1
615 if len(self.input_) > max_length:
616 self.input_ = self.input_[:max_length]
617 elif key == self.keys['help'] and self.mode != self.mode_edit:
618 self.show_help = True
619 elif self.mode == self.mode_login and key == '\n':
620 self.login_name = self.input_
621 self.send('LOGIN ' + quote(self.input_))
623 elif self.mode == self.mode_password and key == '\n':
624 if self.input_ == '':
626 self.password = self.input_
628 self.switch_mode('play')
629 elif self.mode == self.mode_chat and key == '\n':
630 if self.input_ == '':
632 if self.input_[0] == '/': # FIXME fails on empty input
633 if self.input_ in {'/' + self.keys['switch_to_play'], '/play'}:
634 self.switch_mode('play')
635 elif self.input_ in {'/' + self.keys['switch_to_study'], '/study'}:
636 self.switch_mode('study')
637 elif self.input_.startswith('/nick'):
638 tokens = self.input_.split(maxsplit=1)
640 self.send('NICK ' + quote(tokens[1]))
642 self.log_msg('? need login name')
643 #elif self.input_.startswith('/msg'):
644 # tokens = self.input_.split(maxsplit=2)
645 # if len(tokens) == 3:
646 # self.send('QUERY %s %s' % (quote(tokens[1]),
649 # self.log_msg('? need message target and message')
651 self.log_msg('? unknown command')
653 self.send('ALL ' + quote(self.input_))
655 elif self.mode == self.mode_annotate and key == '\n':
656 if self.input_ == '':
658 self.send('ANNOTATE %s %s %s' % (self.explorer, quote(self.input_),
659 quote(self.password)))
661 self.switch_mode('play')
662 elif self.mode == self.mode_portal and key == '\n':
663 if self.input_ == '':
665 self.send('PORTAL %s %s %s' % (self.explorer, quote(self.input_),
666 quote(self.password)))
668 self.switch_mode('play')
669 elif self.mode == self.mode_teleport and key == '\n':
670 if self.input_ == 'YES!':
671 self.host = self.teleport_target_host
674 self.log_msg('@ teleport aborted')
675 self.switch_mode('play')
677 elif self.mode == self.mode_study:
678 if key == self.keys['switch_to_chat']:
679 self.switch_mode('chat')
680 elif key == self.keys['switch_to_play']:
681 self.switch_mode('play')
682 elif key == self.keys['toggle_map_mode']:
683 if self.map_mode == 'terrain':
684 self.map_mode = 'control'
686 self.map_mode = 'terrain'
687 elif key in self.movement_keys:
688 move_explorer(self.movement_keys[key])
689 elif self.mode == self.mode_play:
690 if key == self.keys['switch_to_chat']:
691 self.switch_mode('chat')
692 elif key == self.keys['switch_to_study']:
693 self.switch_mode('study')
694 elif key == self.keys['switch_to_annotate']:
695 self.switch_mode('annotate')
696 elif key == self.keys['switch_to_portal']:
697 self.switch_mode('portal')
698 elif key == self.keys['switch_to_password']:
699 self.switch_mode('password')
700 if key == self.keys['switch_to_edit'] and\
701 'WRITE' in self.game.tasks:
702 self.switch_mode('edit')
703 elif key == self.keys['flatten'] and\
704 'FLATTEN_SURROUNDINGS' in self.game.tasks:
705 self.send('TASK:FLATTEN_SURROUNDINGS ' + quote(self.password))
706 elif key in self.movement_keys and 'MOVE' in self.game.tasks:
707 self.send('TASK:MOVE ' + self.movement_keys[key])
708 elif self.mode == self.mode_edit:
709 self.send('TASK:WRITE %s %s' % (key, quote(self.password)))
710 self.switch_mode('play')
712 TUI('localhost:5000')