home · contact · privacy
Ensure rough feature parity between clients.
[plomrogue2-experiments] / new2 / rogue_chat_curses.py
1 #!/usr/bin/env python3
2 import curses
3 import socket
4 import queue
5 import threading
6 from plomrogue.io_tcp import PlomSocket
7 from plomrogue.game import GameBase
8 from plomrogue.parser import Parser
9 from plomrogue.mapping import YX
10 from plomrogue.things import ThingBase
11 from plomrogue.misc import quote
12
13 def cmd_TURN(game, n):
14     game.turn = n
15     game.things = []
16     game.portals = {}
17     game.turn_complete = False
18 cmd_TURN.argtypes = 'int:nonneg'
19
20 def cmd_LOGIN_OK(game):
21     game.tui.switch_mode('post_login_wait')
22     game.tui.send('GET_GAMESTATE')
23     game.tui.log_msg('@ welcome')
24 cmd_LOGIN_OK.argtypes = ''
25
26 def cmd_CHAT(game, msg):
27     game.tui.log_msg('# ' + msg)
28     game.tui.do_refresh = True
29 cmd_CHAT.argtypes = 'string'
30
31 def cmd_PLAYER_ID(game, player_id):
32     game.player_id = player_id
33 cmd_PLAYER_ID.argtypes = 'int:nonneg'
34
35 def cmd_THING_POS(game, thing_id, position):
36     t = game.get_thing(thing_id, True)
37     t.position = position
38 cmd_THING_POS.argtypes = 'int:nonneg yx_tuple:nonneg'
39
40 def cmd_THING_NAME(game, thing_id, name):
41     t = game.get_thing(thing_id, True)
42     t.name = name
43 cmd_THING_NAME.argtypes = 'int:nonneg string'
44
45 def cmd_MAP(game, size, content):
46     game.map_geometry.size = size
47     game.map_content = content
48 cmd_MAP.argtypes = 'yx_tuple:pos string'
49
50 def cmd_GAME_STATE_COMPLETE(game):
51     game.info_db = {}
52     if game.tui.mode.name == 'post_login_wait':
53         game.tui.switch_mode('play')
54         game.tui.help()
55     if game.tui.mode.shows_info:
56         game.tui.query_info()
57     player = game.get_thing(game.player_id, False)
58     if player.position in game.portals:
59         host, port = game.portals[player.position].split(':')
60         game.tui.teleport_target_host = host
61         game.tui.teleport_target_port = port
62         game.tui.switch_mode('teleport')
63     game.turn_complete = True
64     game.tui.do_refresh = True
65 cmd_GAME_STATE_COMPLETE.argtypes = ''
66
67 def cmd_PORTAL(game, position, msg):
68     game.portals[position] = msg
69 cmd_PORTAL.argtypes = 'yx_tuple:nonneg string'
70
71 def cmd_PLAY_ERROR(game, msg):
72     game.tui.flash()
73     game.tui.do_refresh = True
74 cmd_PLAY_ERROR.argtypes = 'string'
75
76 def cmd_GAME_ERROR(game, msg):
77     game.tui.log_msg('? game error: ' + msg)
78     game.tui.do_refresh = True
79 cmd_GAME_ERROR.argtypes = 'string'
80
81 def cmd_ARGUMENT_ERROR(game, msg):
82     game.tui.log_msg('? syntax error: ' + msg)
83     game.tui.do_refresh = True
84 cmd_ARGUMENT_ERROR.argtypes = 'string'
85
86 def cmd_ANNOTATION(game, position, msg):
87     game.info_db[position] = msg
88     if game.tui.mode.shows_info:
89         game.tui.do_refresh = True
90 cmd_ANNOTATION.argtypes = 'yx_tuple:nonneg string'
91
92 class Game(GameBase):
93     commands = {'LOGIN_OK': cmd_LOGIN_OK,
94                 'CHAT': cmd_CHAT,
95                 'PLAYER_ID': cmd_PLAYER_ID,
96                 'TURN': cmd_TURN,
97                 'THING_POS': cmd_THING_POS,
98                 'THING_NAME': cmd_THING_NAME,
99                 'MAP': cmd_MAP,
100                 'PORTAL': cmd_PORTAL,
101                 'ANNOTATION': cmd_ANNOTATION,
102                 'GAME_STATE_COMPLETE': cmd_GAME_STATE_COMPLETE,
103                 'ARGUMENT_ERROR': cmd_ARGUMENT_ERROR,
104                 'GAME_ERROR': cmd_GAME_ERROR,
105                 'PLAY_ERROR': cmd_PLAY_ERROR}
106     thing_type = ThingBase
107     turn_complete = False
108
109     def __init__(self, *args, **kwargs):
110         super().__init__(*args, **kwargs)
111         self.map_content = ''
112         self.player_id = -1
113         self.info_db = {}
114         self.portals = {}
115
116     def get_command(self, command_name):
117         from functools import partial
118         f = partial(self.commands[command_name], self)
119         f.argtypes = self.commands[command_name].argtypes
120         return f
121
122 class TUI:
123
124     class Mode:
125
126         def __init__(self, name, has_input_prompt=False, shows_info=False,
127                      is_intro = False):
128             self.name = name
129             self.has_input_prompt = has_input_prompt
130             self.shows_info = shows_info
131             self.is_intro = is_intro
132
133     def __init__(self, host, port):
134         self.host = host
135         self.port = port
136         self.mode_play = self.Mode('play')
137         self.mode_study = self.Mode('study', shows_info=True)
138         self.mode_edit = self.Mode('edit')
139         self.mode_annotate = self.Mode('annotate', has_input_prompt=True, shows_info=True)
140         self.mode_portal = self.Mode('portal', has_input_prompt=True, shows_info=True)
141         self.mode_chat = self.Mode('chat', has_input_prompt=True)
142         self.mode_waiting_for_server = self.Mode('waiting_for_server', is_intro=True)
143         self.mode_login = self.Mode('login', has_input_prompt=True, is_intro=True)
144         self.mode_post_login_wait = self.Mode('post_login_wait', is_intro=True)
145         self.mode_teleport = self.Mode('teleport', has_input_prompt=True)
146         self.game = Game()
147         self.game.tui = self
148         self.parser = Parser(self.game)
149         self.log = []
150         self.do_refresh = True
151         self.queue = queue.Queue()
152         self.switch_mode('waiting_for_server')
153         curses.wrapper(self.loop)
154
155     def flash(self):
156         curses.flash()
157
158     def send(self, msg):
159         try:
160             self.socket.send(msg)
161         except BrokenPipeError:
162             self.log_msg('@ server disconnected :(')
163             self.do_refresh = True
164
165     def log_msg(self, msg):
166         self.log += [msg]
167         if len(self.log) > 100:
168             self.log = self.log[-100:]
169
170     def query_info(self):
171         self.send('GET_ANNOTATION ' + str(self.explorer))
172
173     def switch_mode(self, mode_name, keep_position = False):
174         self.mode = getattr(self, 'mode_' + mode_name)
175         if self.mode.shows_info and not keep_position:
176             player = self.game.get_thing(self.game.player_id, False)
177             self.explorer = YX(player.position.y, player.position.x)
178         if self.mode.name == 'waiting_for_server':
179             self.log_msg('@ waiting for server …')
180         elif self.mode.name == 'login':
181             self.log_msg('@ enter username')
182         elif self.mode.name == 'teleport':
183             self.log_msg("@ May teleport to %s:%s" % (self.teleport_target_host,
184                                                       self.teleport_target_port));
185             self.log_msg("@ Enter 'YES!' to affirm.");
186         elif self.mode.name == 'annotate' and self.explorer in self.game.info_db:
187             info = self.game.info_db[self.explorer]
188             if info != '(none)':
189                 self.input_ = info
190         elif self.mode.name == 'portal' and self.explorer in self.game.portals:
191             self.input_ = self.game.portals[self.explorer]
192
193     def help(self):
194         self.log_msg("HELP:");
195         self.log_msg("chat mode commands:");
196         self.log_msg("  :nick NAME - re-name yourself to NAME");
197         self.log_msg("  :msg USER TEXT - send TEXT to USER");
198         self.log_msg("  :help - show this help");
199         self.log_msg("  :p or :play - switch to play mode");
200         self.log_msg("  :? or :study - switch to study mode");
201         self.log_msg("commands common to study and play mode:");
202         self.log_msg("  w,a,s,d - move");
203         self.log_msg("  c - switch to chat mode");
204         self.log_msg("commands specific to play mode:");
205         self.log_msg("  e - write following ASCII character");
206         self.log_msg("  f - flatten surroundings");
207         self.log_msg("  ? - switch to study mode");
208         self.log_msg("commands specific to study mode:");
209         self.log_msg("  e - annotate terrain");
210         self.log_msg("  p - switch to play mode");
211
212     def loop(self, stdscr):
213
214         def safe_addstr(y, x, line):
215             if y < self.size.y - 1 or x + len(line) < self.size.x:
216                 stdscr.addstr(y, x, line)
217             else:  # workaround to <https://stackoverflow.com/q/7063128>
218                 cut_i = self.size.x - x - 1
219                 cut = line[:cut_i]
220                 last_char = line[cut_i]
221                 stdscr.addstr(y, self.size.x - 2, last_char)
222                 stdscr.insstr(y, self.size.x - 2, ' ')
223                 stdscr.addstr(y, x, cut)
224
225         def connect():
226             import time
227
228             def recv_loop():
229                 for msg in self.socket.recv():
230                     if msg == 'BYE':
231                         break
232                     self.queue.put(msg)
233
234             while True:
235                 try:
236                     s = socket.create_connection((self.host, self.port))
237                     self.socket = PlomSocket(s)
238                     self.socket_thread = threading.Thread(target=recv_loop)
239                     self.socket_thread.start()
240                     self.switch_mode('login')
241                     return
242                 except ConnectionRefusedError:
243                     self.log_msg('@ server connect failure, trying again …')
244                     draw_screen()
245                     stdscr.refresh()
246                     time.sleep(1)
247
248         def reconnect():
249             self.send('QUIT')
250             self.switch_mode('waiting_for_server')
251             connect()
252
253         def handle_input(msg):
254             command, args = self.parser.parse(msg)
255             command(*args)
256
257         def msg_into_lines_of_width(msg, width):
258             chunk = ''
259             lines = []
260             x = 0
261             for i in range(len(msg)):
262                 if x >= width or msg[i] == "\n":
263                     lines += [chunk]
264                     chunk = ''
265                     x = 0
266                 if msg[i] != "\n":
267                     chunk += msg[i]
268                 x += 1
269             lines += [chunk]
270             return lines
271
272         def reset_screen_size():
273             self.size = YX(*stdscr.getmaxyx())
274             self.size = self.size - YX(self.size.y % 2, 0)
275             self.size = self.size - YX(0, self.size.x % 4)
276             self.window_width = int(self.size.x / 2)
277
278         def recalc_input_lines():
279             if not self.mode.has_input_prompt:
280                 self.input_lines = []
281             else:
282                 self.input_lines = msg_into_lines_of_width(input_prompt + self.input_,
283                                                            self.window_width)
284
285         def move_explorer(direction):
286             target = self.game.map_geometry.move(self.explorer, direction)
287             if target:
288                 self.explorer = target
289                 self.query_info()
290             else:
291                 self.flash()
292
293         def draw_history():
294             lines = []
295             for line in self.log:
296                 lines += msg_into_lines_of_width(line, self.window_width)
297             lines.reverse()
298             height_header = 2
299             max_y = self.size.y - len(self.input_lines)
300             for i in range(len(lines)):
301                 if (i >= max_y - height_header):
302                     break
303                 safe_addstr(max_y - i - 1, self.window_width, lines[i])
304
305         def draw_info():
306             if not self.game.turn_complete:
307                 return
308             if self.explorer in self.game.portals:
309                 info = 'PORTAL: ' + self.game.portals[self.explorer] + '\n'
310             else:
311                 info = 'PORTAL: (none)\n'
312             if self.explorer in self.game.info_db:
313                 info += 'ANNOTATION: ' + self.game.info_db[self.explorer]
314             else:
315                 info += 'ANNOTATION: waiting …'
316             lines = msg_into_lines_of_width(info, self.window_width)
317             height_header = 2
318             for i in range(len(lines)):
319                 y = height_header + i
320                 if y >= self.size.y - len(self.input_lines):
321                     break
322                 safe_addstr(y, self.window_width, lines[i])
323
324         def draw_input():
325             y = self.size.y - len(self.input_lines)
326             for i in range(len(self.input_lines)):
327                 safe_addstr(y, self.window_width, self.input_lines[i])
328                 y += 1
329
330         def draw_turn():
331             if not self.game.turn_complete:
332                 return
333             safe_addstr(0, self.window_width, 'TURN: ' + str(self.game.turn))
334
335         def draw_mode():
336             safe_addstr(1, self.window_width, 'MODE: ' + self.mode.name)
337
338         def draw_map():
339             if not self.game.turn_complete:
340                 return
341             map_lines_split = []
342             for y in range(self.game.map_geometry.size.y):
343                 start = self.game.map_geometry.size.x * y
344                 end = start + self.game.map_geometry.size.x
345                 map_lines_split += [list(self.game.map_content[start:end])]
346             for t in self.game.things:
347                 map_lines_split[t.position.y][t.position.x] = '@'
348             if self.mode.shows_info:
349                 map_lines_split[self.explorer.y][self.explorer.x] = '?'
350             map_lines = []
351             for line in map_lines_split:
352                 map_lines += [''.join(line)]
353             map_center = YX(int(self.game.map_geometry.size.y / 2),
354                             int(self.game.map_geometry.size.x / 2))
355             window_center = YX(int(self.size.y / 2),
356                                int(self.window_width / 2))
357             player = self.game.get_thing(self.game.player_id, False)
358             center = player.position
359             if self.mode.shows_info:
360                 center = self.explorer
361             offset = center - window_center
362             term_y = max(0, -offset.y)
363             term_x = max(0, -offset.x)
364             map_y = max(0, offset.y)
365             map_x = max(0, offset.x)
366             while (term_y < self.size.y and map_y < self.game.map_geometry.size.y):
367                 to_draw = map_lines[map_y][map_x:self.window_width + offset.x]
368                 safe_addstr(term_y, term_x, to_draw)
369                 term_y += 1
370                 map_y += 1
371
372         def draw_screen():
373             stdscr.clear()
374             recalc_input_lines()
375             if self.mode.has_input_prompt:
376                 draw_input()
377             if self.mode.shows_info:
378                 draw_info()
379             else:
380                 draw_history()
381             draw_mode()
382             if not self.mode.is_intro:
383                 draw_turn()
384                 draw_map()
385
386         curses.curs_set(False)  # hide cursor
387         stdscr.timeout(10)
388         reset_screen_size()
389         self.explorer = YX(0, 0)
390         self.input_ = ''
391         input_prompt = '> '
392         connect()
393         while True:
394             if self.do_refresh:
395                 draw_screen()
396                 self.do_refresh = False
397             while True:
398                 try:
399                     msg = self.queue.get(block=False)
400                     handle_input(msg)
401                 except queue.Empty:
402                     break
403             try:
404                 key = stdscr.getkey()
405                 self.do_refresh = True
406             except curses.error:
407                 continue
408             if key == 'KEY_RESIZE':
409                 reset_screen_size()
410             elif self.mode.has_input_prompt and key == 'KEY_BACKSPACE':
411                 self.input_ = self.input_[:-1]
412             elif self.mode.has_input_prompt and key != '\n':  # Return key
413                 self.input_ += key
414                 max_length = self.window_width * self.size.y - len(input_prompt) - 1
415                 if len(self.input_) > max_length:
416                     self.input_ = self.input_[:max_length]
417             elif self.mode == self.mode_login and key == '\n':
418                 self.send('LOGIN ' + quote(self.input_))
419                 self.input_ = ""
420             elif self.mode == self.mode_chat and key == '\n':
421                 if self.input_[0] == ':':
422                     if self.input_ in {':p', ':play'}:
423                         self.switch_mode('play')
424                     elif self.input_ in {':?', ':study'}:
425                         self.switch_mode('study')
426                     if self.input_ == ':help':
427                         self.help()
428                     if self.input_ == ':reconnect':
429                         reconnect()
430                     elif self.input_.startswith(':nick'):
431                         tokens = self.input_.split(maxsplit=1)
432                         if len(tokens) == 2:
433                             self.send('LOGIN ' + quote(tokens[1]))
434                         else:
435                             self.log_msg('? need login name')
436                     elif self.input_.startswith(':msg'):
437                         tokens = self.input_.split(maxsplit=2)
438                         if len(tokens) == 3:
439                             self.send('QUERY %s %s' % (quote(tokens[1]),
440                                                               quote(tokens[2])))
441                         else:
442                             self.log_msg('? need message target and message')
443                     else:
444                         self.log_msg('? unknown command')
445                 else:
446                     self.send('ALL ' + quote(self.input_))
447                 self.input_ = ""
448             elif self.mode == self.mode_annotate and key == '\n':
449                 if self.input_ == '':
450                     self.input_ = ' '
451                 self.send('ANNOTATE %s %s' % (self.explorer, quote(self.input_)))
452                 self.input_ = ""
453                 self.switch_mode('study', keep_position=True)
454             elif self.mode == self.mode_portal and key == '\n':
455                 if self.input_ == '':
456                     self.input_ = ' '
457                 self.send('PORTAL %s %s' % (self.explorer, quote(self.input_)))
458                 self.input_ = ""
459                 self.switch_mode('study', keep_position=True)
460             elif self.mode == self.mode_teleport and key == '\n':
461                 if self.input_ == 'YES!':
462                     self.host = self.teleport_target_host
463                     self.port = self.teleport_target_port
464                     reconnect()
465                 else:
466                     self.log_msg('@ teleport aborted')
467                     self.switch_mode('play')
468                 self.input_ = ''
469             elif self.mode == self.mode_study:
470                 if key == 'c':
471                     self.switch_mode('chat')
472                 elif key == 'p':
473                     self.switch_mode('play')
474                 elif key == 'A':
475                     self.switch_mode('annotate', keep_position=True)
476                 elif key == 'P':
477                     self.switch_mode('portal', keep_position=True)
478                 elif key == 'w':
479                     move_explorer('UP')
480                 elif key == 'a':
481                     move_explorer('LEFT')
482                 elif key == 's':
483                     move_explorer('DOWN')
484                 elif key == 'd':
485                     move_explorer('RIGHT')
486             elif self.mode == self.mode_play:
487                 if key == 'c':
488                     self.switch_mode('chat')
489                 elif key == '?':
490                     self.switch_mode('study')
491                 if key == 'e':
492                     self.switch_mode('edit')
493                 elif key == 'f':
494                     self.send('TASK:FLATTEN_SURROUNDINGS')
495                 elif key == 'w':
496                     self.send('TASK:MOVE UP')
497                 elif key == 'a':
498                     self.send('TASK:MOVE LEFT')
499                 elif key == 's':
500                     self.send('TASK:MOVE DOWN')
501                 elif key == 'd':
502                     self.send('TASK:MOVE RIGHT')
503             elif self.mode == self.mode_edit:
504                 self.send('TASK:WRITE ' + key)
505                 self.switch_mode('play')
506
507 TUI('127.0.0.1', 5000)