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