6 from functools import partial
10 class ArgumentError(Exception):
14 class ParseError(Exception):
20 def __init__(self, game):
23 def tokenize(self, msg):
24 """Parse msg string into tokens.
26 Separates by ' ' and '\n', but allows whitespace in tokens quoted by
27 '"', and allows escaping within quoted tokens by a prefixed backslash.
46 elif c in {' ', '\n'}:
57 """Parse msg as call to self.game method, return method with arguments.
59 Respects method signatures defined in methods' .argtypes attributes.
61 tokens = self.tokenize(msg)
64 method_candidate = 'cmd_' + tokens[0]
65 if not hasattr(self.game, method_candidate):
67 method = getattr(self.game, method_candidate)
69 if not hasattr(method, 'argtypes'):
72 raise ParseError('Command expects argument(s).')
73 args_candidates = tokens[1:]
74 if not hasattr(method, 'argtypes'):
75 raise ParseError('Command expects no argument(s).')
76 args, kwargs = self.argsparse(method.argtypes, args_candidates)
77 return partial(method, *args, **kwargs)
79 def parse_yx_tuple(self, yx_string):
80 """Parse yx_string as yx_tuple:nonneg argtype, return result."""
82 def get_axis_position_from_argument(axis, token):
83 if len(token) < 3 or token[:2] != axis + ':' or \
84 not token[2:].isdigit():
85 raise ParseError('Non-int arg for ' + axis + ' position.')
88 raise ParseError('Arg for ' + axis + ' position < 1.')
91 tokens = yx_string.split(',')
93 raise ParseError('Wrong number of yx-tuple arguments.')
94 y = get_axis_position_from_argument('Y', tokens[0])
95 x = get_axis_position_from_argument('X', tokens[1])
98 def argsparse(self, signature, args_tokens):
99 """Parse into / return args_tokens as args/kwargs defined by signature.
101 Expects signature to be a ' '-delimited sequence of any of the strings
102 'int:nonneg', 'yx_tuple:nonneg', 'string', defining the respective
105 tmpl_tokens = signature.split()
106 if len(tmpl_tokens) != len(args_tokens):
107 raise ParseError('Number of arguments (' + str(len(args_tokens)) +
108 ') not expected number (' + str(len(tmpl_tokens))
111 for i in range(len(tmpl_tokens)):
112 tmpl = tmpl_tokens[i]
114 if tmpl == 'int:nonneg':
115 if not arg.isdigit():
116 raise ParseError('Argument must be non-negative integer.')
118 elif tmpl == 'yx_tuple:nonneg':
119 args += [self.parse_yx_tuple(arg)]
120 elif tmpl == 'string':
123 raise ParseError('Unknown argument type.')
131 terrain_map = ('?'*5+'\n')*4+'?'*5
135 def __init__(self, position, symbol):
136 self.position = position
140 """Prefix msg plus newline to self.log_text."""
141 self.log_text = msg + '\n' + self.log_text
143 def cmd_THING(self, type_, yx):
144 """Add to self.things at .position yx with .symbol defined by type_."""
146 if type_ == 'TYPE:human':
148 elif type_ == 'TYPE:monster':
150 self.things += [self.Thing(yx, symbol)]
151 cmd_THING.argtypes = 'string yx_tuple:nonneg'
153 def cmd_MAP_SIZE(self, yx):
154 """Set self.map_size to yx, redraw self.terrain_map as '?' cells."""
156 self.map_size = (y, x)
157 self.terrain_map = ''
158 for y in range(self.map_size[0]):
159 self.terrain_map += '?' * self.map_size[1] + '\n'
160 self.terrain_map = self.terrain_map[:-1]
161 cmd_MAP_SIZE.argtypes = 'yx_tuple:nonneg'
163 def cmd_TURN_FINISHED(self, n):
164 """Do nothing. (This may be extended later.)"""
166 cmd_TURN_FINISHED.argtypes = 'int:nonneg'
168 def cmd_NEW_TURN(self, n):
169 """Set self.turn to n, empty self.things."""
172 cmd_NEW_TURN.argtypes = 'int:nonneg'
174 def cmd_TERRAIN(self, terrain_map):
175 """Reset self.terrain_map from terrain_map."""
176 lines = terrain_map.split('\n')
177 if len(lines) != self.map_size[0]:
178 raise ArgumentError('wrong map height')
180 if len(line) != self.map_size[1]:
181 raise ArgumentError('wrong map width')
182 self.terrain_map = terrain_map
183 cmd_TERRAIN.argtypes = 'string'
188 def __init__(self, socket, game):
189 """Set up all urwid widgets we want on the screen."""
191 edit_widget = self.EditToSocketWidget(socket, 'SEND: ')
192 self.map_widget = urwid.Text('', wrap='clip')
193 self.turn_widget = urwid.Text('')
194 self.log_widget = urwid.Text('')
195 map_box = urwid.Padding(self.map_widget, width=50)
196 widget_pile = urwid.Pile([edit_widget, map_box, self.turn_widget,
198 self.top = urwid.Filler(widget_pile, valign='top')
201 """Draw map view from .game.terrain_map, .game.things."""
203 for c in self.game.terrain_map:
205 for t in self.game.things:
206 pos_i = t.position[0] * (self.game.map_size[1] + 1) + t.position[1]
207 whole_map[pos_i] = t.symbol
208 return ''.join(whole_map)
211 """Redraw all non-edit widgets."""
212 self.turn_widget.set_text('TURN: ' + str(self.game.turn))
213 self.log_widget.set_text(self.game.log_text)
214 self.map_widget.set_text(self.draw_map())
216 class EditToSocketWidget(urwid.Edit):
217 """Extends urwid.Edit with socket to send input on 'enter' to."""
219 def __init__(self, socket, *args, **kwargs):
220 super().__init__(*args, **kwargs)
223 def keypress(self, size, key):
224 """Extend super(): on Enter, send .edit_text, and empty it."""
226 return super().keypress(size, key)
227 plom_socket_io.send(self.socket, self.edit_text)
231 class PlomRogueClient:
233 def __init__(self, game, socket):
234 """Build client urwid interface around socket communication.
236 Sets up all widgets for writing to the socket and representing data
237 from it. Sending via a WidgetManager.EditToSocket widget is
238 straightforward; polling the socket for input from the server in
239 parallel to the urwid main loop not so much:
241 The urwid developers warn against sharing urwid resources among
242 threads, so having a socket polling thread for writing to an urwid
243 widget while other widgets are handled in other threads would be
244 dangerous. Urwid developers recommend using urwid's watch_pipe
245 mechanism instead: using a pipe from non-urwid threads into a single
246 urwid thread. We use self.recv_loop_thread to poll the socket, therein
247 write socket.recv output to an object that is then linked to by
248 self.server_output (which is known to the urwid thread), then use the
249 pipe to urwid to trigger it pulling new data from self.server_output to
250 handle via self.handle_input. (We *could* pipe socket.recv output
251 directly, but then we get complicated buffering situations here as well
252 as in the urwid code that receives the pipe output. It's easier to just
253 tell the urwid code where it finds full new server messages to handle.)
256 self.parser = Parser(self.game)
258 self.widget_manager = WidgetManager(self.socket, self.game)
259 self.server_output = []
260 self.urwid_loop = urwid.MainLoop(self.widget_manager.top)
261 self.urwid_pipe_write_fd = self.urwid_loop.watch_pipe(self.
263 self.recv_loop_thread = threading.Thread(target=self.recv_loop)
265 def handle_input(self, trigger):
266 """On input from recv_loop thread, parse and enact commands.
268 Serves as a receiver to urwid's watch_pipe mechanism, with trigger the
269 data that a pipe defined by watch_pipe delivers. To avoid buffering
270 trouble, we don't care for that data beyond the fact that its receival
271 triggers this function: The sender is to write the data it wants to
272 deliver into the container referenced by self.server_output, and just
273 pipe the trigger to inform us about this.
275 If the message delivered is 'BYE', quits Urwid. Otherwise tries to
276 parse it as a command, and enact it. In all cases but the 'BYE', calls
277 self.widget_manager.update.
279 msg = self.server_output[0]
281 raise urwid.ExitMainLoop()
283 command = self.parser.parse(msg)
285 self.game.log('UNHANDLED INPUT: ' + msg)
288 except (ArgumentError, ParseError) as e:
289 self.game.log('ARGUMENT ERROR: ' + msg + '\n' + str(e))
290 self.widget_manager.update()
291 del self.server_output[0]
294 """Loop to receive messages from socket, deliver them to urwid thread.
296 Waits for self.server_output to become empty (this signals that the
297 input handler is finished / ready to receive new input), then writes
298 finished message from socket to self.server_output, then sends a single
299 b' ' through self.urwid_pipe_write_fd to trigger the input handler.
302 for msg in plom_socket_io.recv(self.socket):
303 while len(self.server_output) > 0: # Wait until self.server_output
304 pass # is emptied by input handler.
305 self.server_output += [msg]
306 os.write(self.urwid_pipe_write_fd, b' ')
309 """Run in parallel urwid_loop and recv_loop threads."""
310 self.recv_loop_thread.start()
311 self.urwid_loop.run()
312 self.recv_loop_thread.join()
315 class TestParser(unittest.TestCase):
317 def test_tokenizer(self):
319 self.assertEqual(p.tokenize(''), [])
320 self.assertEqual(p.tokenize(' '), [])
321 self.assertEqual(p.tokenize('abc'), ['abc'])
322 self.assertEqual(p.tokenize('a b\nc "d"'), ['a', 'b', 'c', 'd'])
323 self.assertEqual(p.tokenize('a "b\nc d"'), ['a', 'b\nc d'])
324 self.assertEqual(p.tokenize('a"b"c'), ['abc'])
325 self.assertEqual(p.tokenize('a\\b'), ['a\\b'])
326 self.assertEqual(p.tokenize('"a\\b"'), ['ab'])
327 self.assertEqual(p.tokenize('a"b'), ['ab'])
328 self.assertEqual(p.tokenize('a"\\"b'), ['a"b'])
330 def test_unhandled(self):
332 self.assertEqual(p.parse(''), None)
333 self.assertEqual(p.parse(' '), None)
334 self.assertEqual(p.parse('x'), None)
336 def test_argsparse(self):
338 assertErr = partial(self.assertRaises, ParseError, p.argsparse)
339 assertErr('', ['foo'])
340 assertErr('string', [])
341 assertErr('string string', ['foo'])
342 self.assertEqual(p.argsparse('string', ('foo',)),
344 self.assertEqual(p.argsparse('string string', ('foo', 'bar')),
345 (['foo', 'bar'], {}))
346 assertErr('int:nonneg', [''])
347 assertErr('int:nonneg', ['x'])
348 assertErr('int:nonneg', ['-1'])
349 assertErr('int:nonneg', ['0.1'])
350 self.assertEqual(p.argsparse('int:nonneg', ('0',)),
352 assertErr('yx_tuple:nonneg', ['x'])
353 assertErr('yx_tuple:nonneg', ['Y:0,X:1'])
354 assertErr('yx_tuple:nonneg', ['Y:1,X:0'])
355 assertErr('yx_tuple:nonneg', ['Y:1.1,X:1'])
356 assertErr('yx_tuple:nonneg', ['Y:1,X:1.1'])
357 self.assertEqual(p.argsparse('yx_tuple:nonneg', ('Y:1,X:2',)),
361 if __name__ == '__main__':
363 s = socket.create_connection(('127.0.0.1', 5000))
364 p = PlomRogueClient(game, s)