home · contact · privacy
c8a17c888388dcfb9317896a7d830ac2bd00b9cf
[plomrogue2-experiments] / new2 / rogue_chat_curses.py
1 #!/usr/bin/env python3
2 import curses
3 import queue
4 import threading
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
11
12 from ws4py.client import WebSocketBaseClient
13 class WebSocketClient(WebSocketBaseClient):
14
15     def __init__(self, recv_handler, *args, **kwargs):
16         super().__init__(*args, **kwargs)
17         self.recv_handler = recv_handler
18         self.connect()
19
20     def received_message(self, message):
21         if message.is_text:
22             message = str(message)
23             self.recv_handler(message)
24
25     @property
26     def plom_closed(self):
27         return self.client_terminated
28
29 from plomrogue.io_tcp import PlomSocket
30 class PlomSocketClient(PlomSocket):
31
32     def __init__(self, recv_handler, url):
33         import socket
34         self.recv_handler = recv_handler
35         host, port = url.split(':')
36         super().__init__(socket.create_connection((host, port)))
37
38     def close(self):
39         self.socket.close()
40
41     def run(self):
42         import ssl
43         try:
44             for msg in self.recv():
45                 if msg == 'NEED_SSL':
46                     self.socket = ssl.wrap_socket(self.socket)
47                     continue
48                 self.recv_handler(msg)
49         except BrokenSocketConnection:
50             pass  # we assume socket will be known as dead by now
51
52 def cmd_TURN(game, n):
53     game.turn = n
54     game.things = []
55     game.portals = {}
56     game.turn_complete = False
57 cmd_TURN.argtypes = 'int:nonneg'
58
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 = ''
64
65 def cmd_CHAT(game, msg):
66     game.tui.log_msg('# ' + msg)
67     game.tui.do_refresh = True
68 cmd_CHAT.argtypes = 'string'
69
70 def cmd_PLAYER_ID(game, player_id):
71     game.player_id = player_id
72 cmd_PLAYER_ID.argtypes = 'int:nonneg'
73
74 def cmd_THING_POS(game, thing_id, position):
75     t = game.get_thing(thing_id, True)
76     t.position = position
77 cmd_THING_POS.argtypes = 'int:nonneg yx_tuple:nonneg'
78
79 def cmd_THING_NAME(game, thing_id, name):
80     t = game.get_thing(thing_id, True)
81     t.name = name
82 cmd_THING_NAME.argtypes = 'int:nonneg string'
83
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',
94         }
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',
103         }
104 cmd_MAP.argtypes = 'string:map_geometry yx_tuple:pos string'
105
106 def cmd_GAME_STATE_COMPLETE(game):
107     game.info_db = {}
108     if game.tui.mode.name == 'post_login_wait':
109         game.tui.switch_mode('play')
110         game.tui.help()
111     if game.tui.mode.shows_info:
112         game.tui.query_info()
113     player = game.get_thing(game.player_id, False)
114     if player.position in game.portals:
115         game.tui.teleport_target_host = game.portals[player.position]
116         game.tui.switch_mode('teleport')
117     game.turn_complete = True
118     game.tui.do_refresh = True
119 cmd_GAME_STATE_COMPLETE.argtypes = ''
120
121 def cmd_PORTAL(game, position, msg):
122     game.portals[position] = msg
123 cmd_PORTAL.argtypes = 'yx_tuple:nonneg string'
124
125 def cmd_PLAY_ERROR(game, msg):
126     game.tui.flash()
127     game.tui.do_refresh = True
128 cmd_PLAY_ERROR.argtypes = 'string'
129
130 def cmd_GAME_ERROR(game, msg):
131     game.tui.log_msg('? game error: ' + msg)
132     game.tui.do_refresh = True
133 cmd_GAME_ERROR.argtypes = 'string'
134
135 def cmd_ARGUMENT_ERROR(game, msg):
136     game.tui.log_msg('? syntax error: ' + msg)
137     game.tui.do_refresh = True
138 cmd_ARGUMENT_ERROR.argtypes = 'string'
139
140 def cmd_ANNOTATION(game, position, msg):
141     game.info_db[position] = msg
142     if game.tui.mode.shows_info:
143         game.tui.do_refresh = True
144 cmd_ANNOTATION.argtypes = 'yx_tuple:nonneg string'
145
146 def cmd_PONG(game):
147     pass
148 cmd_PONG.argtypes = ''
149
150 class Game(GameBase):
151     commands = {'LOGIN_OK': cmd_LOGIN_OK,
152                 'PONG': cmd_PONG,
153                 'CHAT': cmd_CHAT,
154                 'PLAYER_ID': cmd_PLAYER_ID,
155                 'TURN': cmd_TURN,
156                 'THING_POS': cmd_THING_POS,
157                 'THING_NAME': cmd_THING_NAME,
158                 'MAP': cmd_MAP,
159                 'PORTAL': cmd_PORTAL,
160                 'ANNOTATION': cmd_ANNOTATION,
161                 'GAME_STATE_COMPLETE': cmd_GAME_STATE_COMPLETE,
162                 'ARGUMENT_ERROR': cmd_ARGUMENT_ERROR,
163                 'GAME_ERROR': cmd_GAME_ERROR,
164                 'PLAY_ERROR': cmd_PLAY_ERROR}
165     thing_type = ThingBase
166     turn_complete = False
167
168     def __init__(self, *args, **kwargs):
169         super().__init__(*args, **kwargs)
170         self.map_content = ''
171         self.player_id = -1
172         self.info_db = {}
173         self.portals = {}
174
175     def get_string_options(self, string_option_type):
176         if string_option_type == 'map_geometry':
177             return ['Hex', 'Square']
178         return None
179
180     def get_command(self, command_name):
181         from functools import partial
182         f = partial(self.commands[command_name], self)
183         f.argtypes = self.commands[command_name].argtypes
184         return f
185
186 class TUI:
187
188     class Mode:
189
190         def __init__(self, name, has_input_prompt=False, shows_info=False,
191                      is_intro = False):
192             self.name = name
193             self.has_input_prompt = has_input_prompt
194             self.shows_info = shows_info
195             self.is_intro = is_intro
196
197     def __init__(self, host):
198         import os
199         import json
200         self.host = host
201         self.mode_play = self.Mode('play')
202         self.mode_study = self.Mode('study', shows_info=True)
203         self.mode_edit = self.Mode('edit')
204         self.mode_annotate = self.Mode('annotate', has_input_prompt=True, shows_info=True)
205         self.mode_portal = self.Mode('portal', has_input_prompt=True, shows_info=True)
206         self.mode_chat = self.Mode('chat', has_input_prompt=True)
207         self.mode_waiting_for_server = self.Mode('waiting_for_server', is_intro=True)
208         self.mode_login = self.Mode('login', has_input_prompt=True, is_intro=True)
209         self.mode_post_login_wait = self.Mode('post_login_wait', is_intro=True)
210         self.mode_teleport = self.Mode('teleport', has_input_prompt=True)
211         self.game = Game()
212         self.game.tui = self
213         self.parser = Parser(self.game)
214         self.log = []
215         self.do_refresh = True
216         self.queue = queue.Queue()
217         self.login_name = None
218         self.switch_mode('waiting_for_server')
219         self.keys = {
220             'switch_to_chat': 't',
221             'switch_to_play': 'p',
222             'switch_to_annotate': 'm',
223             'switch_to_portal': 'P',
224             'switch_to_study': '?',
225             'switch_to_edit': 'm',
226             'flatten': 'F',
227             'hex_move_upleft': 'w',
228             'hex_move_upright': 'e',
229             'hex_move_right': 'd',
230             'hex_move_downright': 'x',
231             'hex_move_downleft': 'y',
232             'hex_move_left': 'a',
233             'square_move_up': 'w',
234             'square_move_left': 'a',
235             'square_move_down': 's',
236             'square_move_right': 'd',
237         }
238         if os.path.isfile('config.json'):
239             with open('config.json', 'r') as f:
240                 keys_conf = json.loads(f.read())
241             for k in keys_conf:
242                 self.keys[k] = keys_conf[k]
243         curses.wrapper(self.loop)
244
245     def flash(self):
246         curses.flash()
247
248     def send(self, msg):
249         try:
250             if hasattr(self.socket, 'plom_closed') and self.socket.plom_closed:
251                 raise BrokenSocketConnection
252             self.socket.send(msg)
253         except (BrokenPipeError, BrokenSocketConnection):
254             self.log_msg('@ server disconnected :(')
255             self.do_refresh = True
256
257     def log_msg(self, msg):
258         self.log += [msg]
259         if len(self.log) > 100:
260             self.log = self.log[-100:]
261
262     def query_info(self):
263         self.send('GET_ANNOTATION ' + str(self.explorer))
264
265     def switch_mode(self, mode_name, keep_position = False):
266         self.mode = getattr(self, 'mode_' + mode_name)
267         if self.mode.shows_info and not keep_position:
268             player = self.game.get_thing(self.game.player_id, False)
269             self.explorer = YX(player.position.y, player.position.x)
270         if self.mode.name == 'waiting_for_server':
271             self.log_msg('@ waiting for server …')
272         elif self.mode.name == 'login':
273             if self.login_name:
274                 self.send('LOGIN ' + quote(self.login_name))
275             else:
276                 self.log_msg('@ enter username')
277         elif self.mode.name == 'teleport':
278             self.log_msg("@ May teleport to %s" % (self.teleport_target_host)),
279             self.log_msg("@ Enter 'YES!' to enthusiastically affirm.");
280         elif self.mode.name == 'annotate' and self.explorer in self.game.info_db:
281             info = self.game.info_db[self.explorer]
282             if info != '(none)':
283                 self.input_ = info
284         elif self.mode.name == 'portal' and self.explorer in self.game.portals:
285             self.input_ = self.game.portals[self.explorer]
286
287     def help(self):
288         self.log_msg("HELP:");
289         self.log_msg("chat mode commands:");
290         self.log_msg("  /nick NAME - re-name yourself to NAME");
291         self.log_msg("  /msg USER TEXT - send TEXT to USER");
292         self.log_msg("  /help - show this help");
293         self.log_msg("  /%s or /play - switch to play mode" % self.keys['switch_to_play']);
294         self.log_msg("  /%s or /study - switch to study mode" % self.keys['switch_to_study']);
295         self.log_msg("commands common to study and play mode:");
296         self.log_msg("  %s - move" % ','.join(self.movement_keys));
297         self.log_msg("  %s - switch to chat mode" % self.keys['switch_to_chat']);
298         self.log_msg("commands specific to play mode:");
299         self.log_msg("  %s - write following ASCII character" % self.keys['switch_to_edit']);
300         self.log_msg("  %s - flatten surroundings" % self.keys['flatten']);
301         self.log_msg("  %s - switch to study mode" % self.keys['switch_to_study']);
302         self.log_msg("commands specific to study mode:");
303         self.log_msg("  %s - annotate terrain" % self.keys['switch_to_annotate']);
304         self.log_msg("  %s - switch to play mode" % self.keys['switch_to_play']);
305
306     def loop(self, stdscr):
307         import time
308         import datetime
309
310         def safe_addstr(y, x, line):
311             if y < self.size.y - 1 or x + len(line) < self.size.x:
312                 stdscr.addstr(y, x, line)
313             else:  # workaround to <https://stackoverflow.com/q/7063128>
314                 cut_i = self.size.x - x - 1
315                 cut = line[:cut_i]
316                 last_char = line[cut_i]
317                 stdscr.addstr(y, self.size.x - 2, last_char)
318                 stdscr.insstr(y, self.size.x - 2, ' ')
319                 stdscr.addstr(y, x, cut)
320
321         def connect():
322
323             def handle_recv(msg):
324                 if msg == 'BYE':
325                     self.socket.close()
326                 else:
327                     self.queue.put(msg)
328
329             socket_client_class = PlomSocketClient
330             if self.host.startswith('ws://') or self.host.startswith('wss://'):
331                 socket_client_class = WebSocketClient
332             while True:
333                 try:
334                     self.socket = socket_client_class(handle_recv, self.host)
335                     self.socket_thread = threading.Thread(target=self.socket.run)
336                     self.socket_thread.start()
337                     self.switch_mode('login')
338                     return
339                 except ConnectionRefusedError:
340                     self.log_msg('@ server connect failure, trying again …')
341                     draw_screen()
342                     stdscr.refresh()
343                     time.sleep(1)
344
345         def reconnect():
346             self.send('QUIT')
347             time.sleep(0.1)  # FIXME necessitated by some some strange SSL race
348                              # conditions with ws4py, find out what exactly
349             self.switch_mode('waiting_for_server')
350             connect()
351
352         def handle_input(msg):
353             command, args = self.parser.parse(msg)
354             command(*args)
355
356         def msg_into_lines_of_width(msg, width):
357             chunk = ''
358             lines = []
359             x = 0
360             for i in range(len(msg)):
361                 if x >= width or msg[i] == "\n":
362                     lines += [chunk]
363                     chunk = ''
364                     x = 0
365                 if msg[i] != "\n":
366                     chunk += msg[i]
367                 x += 1
368             lines += [chunk]
369             return lines
370
371         def reset_screen_size():
372             self.size = YX(*stdscr.getmaxyx())
373             self.size = self.size - YX(self.size.y % 4, 0)
374             self.size = self.size - YX(0, self.size.x % 4)
375             self.window_width = int(self.size.x / 2)
376
377         def recalc_input_lines():
378             if not self.mode.has_input_prompt:
379                 self.input_lines = []
380             else:
381                 self.input_lines = msg_into_lines_of_width(input_prompt + self.input_,
382                                                            self.window_width)
383
384         def move_explorer(direction):
385             target = self.game.map_geometry.move(self.explorer, direction)
386             if target:
387                 self.explorer = target
388                 self.query_info()
389             else:
390                 self.flash()
391
392         def draw_history():
393             lines = []
394             for line in self.log:
395                 lines += msg_into_lines_of_width(line, self.window_width)
396             lines.reverse()
397             height_header = 2
398             max_y = self.size.y - len(self.input_lines)
399             for i in range(len(lines)):
400                 if (i >= max_y - height_header):
401                     break
402                 safe_addstr(max_y - i - 1, self.window_width, lines[i])
403
404         def draw_info():
405             if not self.game.turn_complete:
406                 return
407             pos_i = self.explorer.y * self.game.map_geometry.size.x + self.explorer.x
408             info = 'TERRAIN: %s\n' % self.game.map_content[pos_i]
409             for t in self.game.things:
410                 if t.position == self.explorer:
411                     info += 'PLAYER @: %s\n' % t.name
412             if self.explorer in self.game.portals:
413                 info += 'PORTAL: ' + self.game.portals[self.explorer] + '\n'
414             else:
415                 info += 'PORTAL: (none)\n'
416             if self.explorer in self.game.info_db:
417                 info += 'ANNOTATION: ' + self.game.info_db[self.explorer]
418             else:
419                 info += 'ANNOTATION: waiting …'
420             lines = msg_into_lines_of_width(info, self.window_width)
421             height_header = 2
422             for i in range(len(lines)):
423                 y = height_header + i
424                 if y >= self.size.y - len(self.input_lines):
425                     break
426                 safe_addstr(y, self.window_width, lines[i])
427
428         def draw_input():
429             y = self.size.y - len(self.input_lines)
430             for i in range(len(self.input_lines)):
431                 safe_addstr(y, self.window_width, self.input_lines[i])
432                 y += 1
433
434         def draw_turn():
435             if not self.game.turn_complete:
436                 return
437             safe_addstr(0, self.window_width, 'TURN: ' + str(self.game.turn))
438
439         def draw_mode():
440             safe_addstr(1, self.window_width, 'MODE: ' + self.mode.name)
441
442         def draw_map():
443             if not self.game.turn_complete:
444                 return
445             map_lines_split = []
446             for y in range(self.game.map_geometry.size.y):
447                 start = self.game.map_geometry.size.x * y
448                 end = start + self.game.map_geometry.size.x
449                 map_lines_split += [list(self.game.map_content[start:end])]
450             for t in self.game.things:
451                 map_lines_split[t.position.y][t.position.x] = '@'
452             if self.mode.shows_info:
453                 map_lines_split[self.explorer.y][self.explorer.x] = '?'
454             map_lines = []
455             if type(self.game.map_geometry) == MapGeometryHex:
456                 indent = 0
457                 for line in map_lines_split:
458                     map_lines += [indent*' ' + ' '.join(line)]
459                     indent = 0 if indent else 1
460             else:
461                 for line in map_lines_split:
462                     map_lines += [' '.join(line)]
463             window_center = YX(int(self.size.y / 2),
464                                int(self.window_width / 2))
465             player = self.game.get_thing(self.game.player_id, False)
466             center = player.position
467             if self.mode.shows_info:
468                 center = self.explorer
469             center = YX(center.y, center.x * 2)
470             offset = center - window_center
471             if type(self.game.map_geometry) == MapGeometryHex and offset.y % 2:
472                 offset += YX(0, 1)
473             term_y = max(0, -offset.y)
474             term_x = max(0, -offset.x)
475             map_y = max(0, offset.y)
476             map_x = max(0, offset.x)
477             while (term_y < self.size.y and map_y < self.game.map_geometry.size.y):
478                 to_draw = map_lines[map_y][map_x:self.window_width + offset.x]
479                 safe_addstr(term_y, term_x, to_draw)
480                 term_y += 1
481                 map_y += 1
482
483         def draw_screen():
484             stdscr.clear()
485             recalc_input_lines()
486             if self.mode.has_input_prompt:
487                 draw_input()
488             if self.mode.shows_info:
489                 draw_info()
490             else:
491                 draw_history()
492             draw_mode()
493             if not self.mode.is_intro:
494                 draw_turn()
495                 draw_map()
496
497         curses.curs_set(False)  # hide cursor
498         curses.use_default_colors();
499         stdscr.timeout(10)
500         reset_screen_size()
501         self.explorer = YX(0, 0)
502         self.input_ = ''
503         input_prompt = '> '
504         connect()
505         last_ping = datetime.datetime.now()
506         interval = datetime.timedelta(seconds=30)
507         while True:
508             now = datetime.datetime.now()
509             if now - last_ping > interval:
510                 self.send('PING')
511                 last_ping = now
512             if self.do_refresh:
513                 draw_screen()
514                 self.do_refresh = False
515             while True:
516                 try:
517                     msg = self.queue.get(block=False)
518                     handle_input(msg)
519                 except queue.Empty:
520                     break
521             try:
522                 key = stdscr.getkey()
523                 self.do_refresh = True
524             except curses.error:
525                 continue
526             if key == 'KEY_RESIZE':
527                 reset_screen_size()
528             elif self.mode.has_input_prompt and key == 'KEY_BACKSPACE':
529                 self.input_ = self.input_[:-1]
530             elif self.mode.has_input_prompt and key != '\n':  # Return key
531                 self.input_ += key
532                 max_length = self.window_width * self.size.y - len(input_prompt) - 1
533                 if len(self.input_) > max_length:
534                     self.input_ = self.input_[:max_length]
535             elif self.mode == self.mode_login and key == '\n':
536                 self.login_name = self.input_
537                 self.send('LOGIN ' + quote(self.input_))
538                 self.input_ = ""
539             elif self.mode == self.mode_chat and key == '\n':
540                 if self.input_[0] == '/':
541                     if self.input_ in {'/' + self.keys['switch_to_play'], '/play'}:
542                         self.switch_mode('play')
543                     elif self.input_ in {'/' + self.keys['switch_to_study'], '/study'}:
544                         self.switch_mode('study')
545                     elif self.input_ == '/help':
546                         self.help()
547                     elif self.input_ == '/reconnect':
548                         reconnect()
549                     elif self.input_.startswith('/nick'):
550                         tokens = self.input_.split(maxsplit=1)
551                         if len(tokens) == 2:
552                             self.send('LOGIN ' + quote(tokens[1]))
553                         else:
554                             self.log_msg('? need login name')
555                     elif self.input_.startswith('/msg'):
556                         tokens = self.input_.split(maxsplit=2)
557                         if len(tokens) == 3:
558                             self.send('QUERY %s %s' % (quote(tokens[1]),
559                                                               quote(tokens[2])))
560                         else:
561                             self.log_msg('? need message target and message')
562                     else:
563                         self.log_msg('? unknown command')
564                 else:
565                     self.send('ALL ' + quote(self.input_))
566                 self.input_ = ""
567             elif self.mode == self.mode_annotate and key == '\n':
568                 if self.input_ == '':
569                     self.input_ = ' '
570                 self.send('ANNOTATE %s %s' % (self.explorer, quote(self.input_)))
571                 self.input_ = ""
572                 self.switch_mode('study', keep_position=True)
573             elif self.mode == self.mode_portal and key == '\n':
574                 if self.input_ == '':
575                     self.input_ = ' '
576                 self.send('PORTAL %s %s' % (self.explorer, quote(self.input_)))
577                 self.input_ = ""
578                 self.switch_mode('study', keep_position=True)
579             elif self.mode == self.mode_teleport and key == '\n':
580                 if self.input_ == 'YES!':
581                     self.host = self.teleport_target_host
582                     reconnect()
583                 else:
584                     self.log_msg('@ teleport aborted')
585                     self.switch_mode('play')
586                 self.input_ = ''
587             elif self.mode == self.mode_study:
588                 if key == self.keys['switch_to_chat']:
589                     self.switch_mode('chat')
590                 elif key == self.keys['switch_to_play']:
591                     self.switch_mode('play')
592                 elif key == self.keys['switch_to_annotate']:
593                     self.switch_mode('annotate', keep_position=True)
594                 elif key == self.keys['switch_to_portal']:
595                     self.switch_mode('portal', keep_position=True)
596                 elif key in self.movement_keys:
597                     move_explorer(self.movement_keys[key])
598             elif self.mode == self.mode_play:
599                 if key == self.keys['switch_to_chat']:
600                     self.switch_mode('chat')
601                 elif key == self.keys['switch_to_study']:
602                     self.switch_mode('study')
603                 if key == self.keys['switch_to_edit']:
604                     self.switch_mode('edit')
605                 elif key == self.keys['flatten']:
606                     self.send('TASK:FLATTEN_SURROUNDINGS')
607                 elif key in self.movement_keys:
608                     self.send('TASK:MOVE ' + self.movement_keys[key])
609             elif self.mode == self.mode_edit:
610                 self.send('TASK:WRITE ' + key)
611                 self.switch_mode('play')
612
613 TUI('localhost:5000')