9 class GameError(Exception):
13 # Avoid "Address already in use" errors.
14 socketserver.TCPServer.allow_reuse_address = True
17 class Server(socketserver.ThreadingTCPServer):
18 """Bind together threaded IO handling server and message queue."""
20 def __init__(self, queue, port, *args, **kwargs):
21 super().__init__(('localhost', port), IO_Handler, *args, **kwargs)
22 self.queue_out = queue
23 self.daemon_threads = True # Else, server's threads have daemon=False.
26 class IO_Handler(socketserver.BaseRequestHandler):
29 """Move messages between network socket and game IO loop via queues.
31 On start (a new connection from client to server), sets up a
32 new queue, sends it via self.server.queue_out to the game IO
33 loop thread, and from then on receives messages to send back
34 from the game IO loop via that new queue.
36 At the same time, loops over socket's recv to get messages
37 from the outside into the game IO loop by way of
38 self.server.queue_out into the game IO. Ends connection once a
39 'QUIT' message is received from socket, and then also calls
40 for a kill of its own queue.
42 All messages to the game IO loop are tuples, with the first
43 element a meta command ('ADD_QUEUE' for queue creation,
44 'KILL_QUEUE' for queue deletion, and 'COMMAND' for everything
45 else), the second element a UUID that uniquely identifies the
46 thread (so that the game IO loop knows whom to send replies
47 back to), and optionally a third element for further
52 def send_queue_messages(plom_socket, queue_in, thread_alive):
53 """Send messages via socket from queue_in while thread_alive[0]."""
54 while thread_alive[0]:
56 msg = queue_in.get(timeout=1)
59 plom_socket.send(msg, True)
63 plom_socket = plom_socket.PlomSocket(self.request)
64 print('CONNECTION FROM:', str(self.client_address))
65 connection_id = uuid.uuid4()
66 queue_in = queue.Queue()
67 self.server.queue_out.put(('ADD_QUEUE', connection_id, queue_in))
69 t = threading.Thread(target=send_queue_messages,
70 args=(plom_socket, queue_in, thread_alive))
72 for message in plom_socket.recv():
74 plom_socket.send('BAD MESSAGE', True)
75 elif 'QUIT' == message:
76 plom_socket.send('BYE', True)
79 self.server.queue_out.put(('COMMAND', connection_id, message))
80 self.server.queue_out.put(('KILL_QUEUE', connection_id))
81 thread_alive[0] = False
82 print('CONNECTION CLOSED FROM:', str(self.client_address))
83 plom_socket.socket.close()
88 def __init__(self, game_file_name, game):
89 self.game_file_name = game_file_name
91 self.parser = parser.Parser(game)
94 """Handle commands coming through queue q, send results back.
96 Commands from q are expected to be tuples, with the first element
97 either 'ADD_QUEUE', 'COMMAND', or 'KILL_QUEUE', the second element
98 a UUID, and an optional third element of arbitrary type. The UUID
99 identifies a receiver for replies.
101 An 'ADD_QUEUE' command should contain as third element a queue
102 through which to send messages back to the sender of the
103 command. A 'KILL_QUEUE' command removes the queue for that
104 receiver from the list of queues through which to send replies.
106 A 'COMMAND' command is specified in greater detail by a string
107 that is the tuple's third element. The game_command_handler takes
108 care of processing this and sending out replies.
115 content = None if len(x) == 2 else x[2]
116 if command_type == 'ADD_QUEUE':
117 self.queues_out[connection_id] = content
118 elif command_type == 'KILL_QUEUE':
119 del self.queues_out[connection_id]
120 elif command_type == 'COMMAND':
121 self.handle_input(content, connection_id)
123 def run_loop_with_server(self):
124 """Run connection of server talking to clients and game IO loop.
126 We have the TCP server (an instance of Server) and we have the
127 game IO loop, a thread running self.loop. Both communicate with
128 each other via a queue.Queue. While the TCP server may spawn
129 parallel threads to many clients, the IO loop works sequentially
130 through game commands received from the TCP server's threads (=
131 client connections to the TCP server). A processed command may
132 trigger messages to the commanding client or to all clients,
133 delivered from the IO loop to the TCP server via the queue.
137 c = threading.Thread(target=self.loop, daemon=True, args=(q,))
139 server = Server(q, 5000)
141 server.serve_forever()
142 except KeyboardInterrupt:
145 print('Killing server')
146 server.server_close()
148 def handle_input(self, input_, connection_id=None, store=True):
149 """Process input_ to command grammar, call command handler if found."""
150 from inspect import signature
152 def answer(connection_id, msg):
154 self.send(msg, connection_id)
159 command, args = self.parser.parse(input_)
161 answer(connection_id, 'UNHANDLED_INPUT')
163 if 'connection_id' in list(signature(command).parameters):
164 command(*args, connection_id=connection_id)
167 if store and not hasattr(command, 'dont_save'):
168 with open(self.game_file_name, 'a') as f:
169 f.write(input_ + '\n')
170 except parser.ArgError as e:
171 answer(connection_id, 'ARGUMENT_ERROR ' + quote(str(e)))
172 except GameError as e:
173 answer(connection_id, 'GAME_ERROR ' + quote(str(e)))
175 def send(self, msg, connection_id=None):
176 """Send message msg to server's client(s) via self.queues_out.
178 If a specific client is identified by connection_id, only
179 sends msg to that one. Else, sends it to all clients
180 identified in self.queues_out.
184 self.queues_out[connection_id].put(msg)
186 for connection_id in self.queues_out:
187 self.queues_out[connection_id].put(msg)
192 def __init__(self, size=(0, 0)):
194 self.terrain = '?'*self.size_i
198 return self.size[0] * self.size[1]
200 def set_line(self, y, line):
201 height_map = self.size[0]
202 width_map = self.size[1]
204 raise ArgError('too large row number %s' % y)
205 width_line = len(line)
206 if width_line > width_map:
207 raise ArgError('too large map line width %s' % width_line)
208 self.terrain = self.terrain[:y * width_map] + line +\
209 self.terrain[(y + 1) * width_map:]
211 def get_position_index(self, yx):
212 return yx[0] * self.size[1] + yx[1]
217 def __getitem__(self, yx):
218 return self.terrain[self.get_position_index(yx)]
220 def __setitem__(self, yx, c):
221 pos_i = self.get_position_index(yx)
223 self.terrain = self.terrain[:pos_i] + c + self.terrain[pos_i + 1:]
225 self.terrain[pos_i] = c
228 """Iterate over YX position coordinates."""
229 for y in range(self.size[0]):
230 for x in range(self.size[1]):
235 for y in range(self.size[0]):
236 yield (y, self.terrain[y * width:(y + 1) * width])
238 def get_fov_map(self, yx):
239 return self.fov_map_type(self, yx)
241 def get_directions(self):
243 for name in dir(self):
244 if name[:5] == 'move_':
245 directions += [name[5:]]
248 def get_neighbors(self, pos):
250 if not hasattr(self, 'neighbors_to'):
251 self.neighbors_to = {}
252 if pos in self.neighbors_to:
253 return self.neighbors_to[pos]
254 for direction in self.get_directions():
255 neighbors[direction] = None
257 neighbors[direction] = self.move(pos, direction)
260 self.neighbors_to[pos] = neighbors
263 def new_from_shape(self, init_char):
265 new_map = copy.deepcopy(self)
267 new_map[pos] = init_char
270 def move(self, start_pos, direction):
271 mover = getattr(self, 'move_' + direction)
272 new_pos = mover(start_pos)
273 if new_pos[0] < 0 or new_pos[1] < 0 or \
274 new_pos[0] >= self.size[0] or new_pos[1] >= self.size[1]:
275 raise GameError('would move outside map bounds')
278 def move_LEFT(self, start_pos):
279 return [start_pos[0], start_pos[1] - 1]
281 def move_RIGHT(self, start_pos):
282 return [start_pos[0], start_pos[1] + 1]
288 def __init__(self, *args, **kwargs):
289 super().__init__(*args, **kwargs)
290 self.fov_map_type = FovMapHex
292 def move_UPLEFT(self, start_pos):
293 if start_pos[0] % 2 == 1:
294 return [start_pos[0] - 1, start_pos[1] - 1]
296 return [start_pos[0] - 1, start_pos[1]]
298 def move_UPRIGHT(self, start_pos):
299 if start_pos[0] % 2 == 1:
300 return [start_pos[0] - 1, start_pos[1]]
302 return [start_pos[0] - 1, start_pos[1] + 1]
304 def move_DOWNLEFT(self, start_pos):
305 if start_pos[0] % 2 == 1:
306 return [start_pos[0] + 1, start_pos[1] - 1]
308 return [start_pos[0] + 1, start_pos[1]]
310 def move_DOWNRIGHT(self, start_pos):
311 if start_pos[0] % 2 == 1:
312 return [start_pos[0] + 1, start_pos[1]]
314 return [start_pos[0] + 1, start_pos[1] + 1]
320 def __init__(self, source_map, yx):
321 self.source_map = source_map
322 self.size = self.source_map.size
323 self.terrain = '?' * self.size_i
325 self.shadow_cones = []
326 self.circle_out(yx, self.shadow_process_hex)
328 def shadow_process_hex(self, yx, distance_to_center, dir_i, dir_progress):
329 # Possible optimization: If no shadow_cones yet and self[yx] == '.',
331 CIRCLE = 360 # Since we'll float anyways, number is actually arbitrary.
333 def correct_arm(arm):
338 def in_shadow_cone(new_cone):
339 for old_cone in self.shadow_cones:
340 if old_cone[0] >= new_cone[0] and \
341 new_cone[1] >= old_cone[1]:
342 #print('DEBUG shadowed by:', old_cone)
344 # We might want to also shade hexes whose middle arm is inside a
345 # shadow cone for a darker FOV. Note that we then could not for
346 # optimization purposes rely anymore on the assumption that a
347 # shaded hex cannot add growth to existing shadow cones.
350 def merge_cone(new_cone):
352 for old_cone in self.shadow_cones:
353 if new_cone[0] > old_cone[0] and \
354 (new_cone[1] < old_cone[0] or
355 math.isclose(new_cone[1], old_cone[0])):
356 #print('DEBUG merging to', old_cone)
357 old_cone[0] = new_cone[0]
358 #print('DEBUG merged cone:', old_cone)
360 if new_cone[1] < old_cone[1] and \
361 (new_cone[0] > old_cone[1] or
362 math.isclose(new_cone[0], old_cone[1])):
363 #print('DEBUG merging to', old_cone)
364 old_cone[1] = new_cone[1]
365 #print('DEBUG merged cone:', old_cone)
370 #print('DEBUG CONE', cone, '(', step_size, distance_to_center, number_steps, ')')
371 if in_shadow_cone(cone):
374 if self.source_map[yx] != '.':
375 #print('DEBUG throws shadow', cone)
377 while merge_cone(cone):
380 self.shadow_cones += [cone]
383 step_size = (CIRCLE/len(self.circle_out_directions)) / distance_to_center
384 number_steps = dir_i * distance_to_center + dir_progress
385 left_arm = correct_arm(-(step_size/2) - step_size*number_steps)
386 right_arm = correct_arm(left_arm - step_size)
387 # Optimization potential: left cone could be derived from previous
388 # right cone. Better even: Precalculate all cones.
389 if right_arm > left_arm:
390 eval_cone([left_arm, 0])
391 eval_cone([CIRCLE, right_arm])
393 eval_cone([left_arm, right_arm])
395 def basic_circle_out_move(self, pos, direction):
396 """Move position pos into direction. Return whether still in map."""
397 mover = getattr(self, 'move_' + direction)
399 if pos[0] < 0 or pos[1] < 0 or \
400 pos[0] >= self.size[0] or pos[1] >= self.size[1]:
404 def circle_out(self, yx, f):
405 # Optimization potential: Precalculate movement positions. (How to check
406 # circle_in_map then?)
407 # Optimization potential: Precalculate what hexes are shaded by what hex
408 # and skip evaluation of already shaded hexes. (This only works if hex
409 # shading implies they completely lie in existing shades; otherwise we
410 # would lose shade growth through hexes at shade borders.)
412 # TODO: Start circling only in earliest obstacle distance.
416 #print('DEBUG CIRCLE_OUT', yx)
418 circle_in_map = False
419 self.basic_circle_out_move(yx, 'RIGHT')
420 for dir_i in range(len(self.circle_out_directions)):
421 for dir_progress in range(distance):
422 direction = self.circle_out_directions[dir_i]
423 if self.circle_out_move(yx, direction):
424 f(yx, distance, dir_i, dir_progress)
430 class FovMapHex(FovMap, MapHex):
431 circle_out_directions = ('DOWNLEFT', 'LEFT', 'UPLEFT',
432 'UPRIGHT', 'RIGHT', 'DOWNRIGHT')
434 def circle_out_move(self, yx, direction):
435 return self.basic_circle_out_move(yx, direction)
441 def __init__(self, world, id_, type_='?', position=[0,0]):
445 self.position = position
448 class Thing(ThingBase):
450 def __init__(self, *args, **kwargs):
451 super().__init__(*args, **kwargs)
452 self.set_task('WAIT')
453 self._last_task_result = None
456 def move_towards_target(self, target):
457 dijkstra_map = type(self.world.map_)(self.world.map_.size)
459 dijkstra_map.terrain = [n_max for i in range(dijkstra_map.size_i)]
460 dijkstra_map[target] = 0
462 visible_map = self.get_visible_map()
465 for pos in dijkstra_map:
466 if visible_map[pos] != '.':
468 neighbors = dijkstra_map.get_neighbors(tuple(pos))
469 for direction in neighbors:
470 yx = neighbors[direction]
471 if yx is not None and dijkstra_map[yx] < dijkstra_map[pos] - 1:
472 dijkstra_map[pos] = dijkstra_map[yx] + 1
474 #with open('log', 'a') as f:
475 # f.write('---------------------------------\n')
476 # for y, line in dijkstra_map.lines():
485 neighbors = dijkstra_map.get_neighbors(tuple(self.position))
487 #print('DEBUG', self.position, neighbors)
488 #dirs = dijkstra_map.get_directions()
489 #print('DEBUG dirs', dirs)
490 #print('DEBUG neighbors', neighbors)
492 #for pos in neighbors:
494 # debug_scores += [9000]
496 # debug_scores += [dijkstra_map[pos]]
497 #print('DEBUG debug_scores', debug_scores)
498 target_direction = None
499 for direction in neighbors:
500 yx = neighbors[direction]
502 n_new = dijkstra_map[yx]
505 target_direction = direction
506 #print('DEBUG result', direction)
508 self.set_task('MOVE', (target_direction,))
510 def decide_task(self):
511 # TODO: Check if monster can follow player too well (even when they should lose them)
512 visible_things = self.get_visible_things()
514 for t in visible_things:
515 if t.type_ == 'human':
518 if target is not None:
520 self.move_towards_target(target)
524 self.set_task('WAIT')
526 def set_task(self, task_name, args=()):
527 task_class = self.world.game.tasks[task_name]
528 self.task = task_class(self, args)
529 self.task.check() # will throw GameError if necessary
531 def proceed(self, is_AI=True):
532 """Further the thing in its tasks.
534 Decrements .task.todo; if it thus falls to <= 0, enacts method
535 whose name is 'task_' + self.task.name and sets .task =
536 None. If is_AI, calls .decide_task to decide a self.task.
538 Before doing anything, ensures an empty map visibility stencil
539 and checks that task is still possible, and aborts it
540 otherwise (for AI things, decides a new task).
546 except GameError as e:
548 self._last_task_result = e
553 self.set_task('WAIT')
556 if self.task.todo <= 0:
557 self._last_task_result = self.task.do()
559 if is_AI and self.task is None:
563 self.set_task('WAIT')
565 def get_stencil(self):
566 if self._stencil is not None:
568 self._stencil = self.world.map_.get_fov_map(self.position)
571 def get_visible_map(self):
572 stencil = self.get_stencil()
573 m = self.world.map_.new_from_shape(' ')
575 if stencil[pos] == '.':
576 m[pos] = self.world.map_[pos]
579 def get_visible_things(self):
580 stencil = self.get_stencil()
582 for thing in self.world.things:
583 if stencil[thing.position] == '.':
584 visible_things += [thing]
585 return visible_things
592 def __init__(self, thing, args=()):
600 class_name = self.__class__.__name__
601 return class_name[len(prefix):]
606 def get_args_string(self):
608 for arg in self.args:
610 stringed_args += [quote(arg)]
612 raise GameError('stringifying arg type not implemented')
613 return ' '.join(stringed_args)
617 class Task_WAIT(Task):
624 class Task_MOVE(Task):
625 argtypes = 'string:direction'
628 test_pos = self.thing.world.map_.move(self.thing.position, self.args[0])
629 if self.thing.world.map_[test_pos] != '.':
630 raise GameError('%s would move into illegal terrain' % self.thing.id_)
631 for t in self.thing.world.things:
632 if t.position == test_pos:
633 raise GameError('%s would move into other thing' % self.thing.id_)
636 self.thing.position = self.thing.world.map_.move(self.thing.position,
643 def __init__(self, game):
648 def get_thing(self, id_, create_unfound=True):
649 for thing in self.things:
653 t = self.game.thing_type(self, id_)
659 class World(WorldBase):
661 def __init__(self, *args, **kwargs):
662 super().__init__(*args, **kwargs)
665 def new_map(self, yx):
666 self.map_ = self.game.map_type(yx)
668 def proceed_to_next_player_turn(self):
669 """Run game world turns until player can decide their next step.
671 Iterates through all non-player things, on each step
672 furthering them in their tasks (and letting them decide new
673 ones if they finish). The iteration order is: first all things
674 that come after the player in the world things list, then
675 (after incrementing the world turn) all that come before the
676 player; then the player's .proceed() is run, and if it does
677 not finish his task, the loop starts at the beginning. Once
678 the player's task is finished, the loop breaks.
681 player = self.get_player()
682 player_i = self.things.index(player)
683 for thing in self.things[player_i+1:]:
686 for thing in self.things[:player_i]:
688 player.proceed(is_AI=False)
689 if player.task is None:
692 def get_player(self):
693 return self.get_thing(self.player_id)
695 def make_new(self, yx, seed):
700 for pos in self.map_:
701 if 0 in pos or (yx[0] - 1) == pos[0] or (yx[1] - 1) == pos[1]:
704 self.map_[pos] = random.choice(('.', '.', '.', '.', 'x'))
705 player = self.game.thing_type(self, 0)
706 player.type_ = 'human'
707 player.position = [random.randint(0, yx[0] -1),
708 random.randint(0, yx[1] - 1)]
709 npc = self.game.thing_type(self, 1)
710 npc.type_ = 'monster'
711 npc.position = [random.randint(0, yx[0] -1),
712 random.randint(0, yx[1] -1)]
713 self.things = [player, npc]
718 def cmd_GEN_WORLD(self, yx, seed):
719 self.world.make_new(yx, seed)
720 cmd_GEN_WORLD.argtypes = 'yx_tuple:pos string'
722 def cmd_GET_GAMESTATE(self, connection_id):
723 """Send game state to caller."""
724 self.send_gamestate(connection_id)
726 def cmd_MAP(self, yx):
727 """Create new map of size yx and only '?' cells."""
728 self.world.new_map(yx)
729 cmd_MAP.argtypes = 'yx_tuple:pos'
731 def cmd_THING_TYPE(self, i, type_):
732 t = self.world.get_thing(i)
734 cmd_THING_TYPE.argtypes = 'int:nonneg string'
736 def cmd_THING_POS(self, i, yx):
737 t = self.world.get_thing(i)
738 t.position = list(yx)
739 cmd_THING_POS.argtypes = 'int:nonneg yx_tuple:nonneg'
741 def cmd_TERRAIN_LINE(self, y, terrain_line):
742 self.world.map_.set_line(y, terrain_line)
743 cmd_TERRAIN_LINE.argtypes = 'int:nonneg string'
745 def cmd_PLAYER_ID(self, id_):
746 # TODO: test whether valid thing ID
747 self.world.player_id = id_
748 cmd_PLAYER_ID.argtypes = 'int:nonneg'
750 def cmd_TURN(self, n):
752 cmd_TURN.argtypes = 'int:nonneg'
754 def cmd_SWITCH_PLAYER(self):
755 player = self.world.get_player()
756 player.set_task('WAIT')
757 thing_ids = [t.id_ for t in self.world.things]
758 player_index = thing_ids.index(player.id_)
759 if player_index == len(thing_ids) - 1:
760 self.world.player_id = thing_ids[0]
762 self.world.player_id = thing_ids[player_index + 1]
770 save_file_name = self.io.game_file_name + '.save'
771 with open(save_file_name, 'w') as f:
772 write(f, 'TURN %s' % self.world.turn)
773 write(f, 'MAP ' + stringify_yx(self.world.map_.size))
774 for y, line in self.world.map_.lines():
775 write(f, 'TERRAIN_LINE %5s %s' % (y, quote(line)))
776 for thing in self.world.things:
777 write(f, 'THING_TYPE %s %s' % (thing.id_, thing.type_))
778 write(f, 'THING_POS %s %s' % (thing.id_,
779 stringify_yx(thing.position)))
782 task_args = task.get_args_string()
783 write(f, 'SET_TASK:%s %s %s %s' % (task.name, thing.id_,
784 task.todo, task_args))
785 write(f, 'PLAYER_ID %s' % self.world.player_id)
786 cmd_SAVE.dont_save = True
791 def __init__(self, game_file_name):
792 self.io = GameIO(game_file_name, self)
793 self.map_type = MapHex
794 self.tasks = {'WAIT': Task_WAIT, 'MOVE': Task_MOVE}
795 self.commands = {'GEN_WORLD': cmd_GEN_WORLD,
796 'GET_GAMESTATE': cmd_GET_GAMESTATE,
798 'THING_TYPE': cmd_THING_TYPE,
799 'THING_POS': cmd_THING_POS,
800 'TERRAIN_LINE': cmd_TERRAIN_LINE,
801 'PLAYER_ID': cmd_PLAYER_ID,
803 'SWITCH_PLAYER': cmd_SWITCH_PLAYER,
805 self.world_type = World
806 self.world = self.world_type(self)
807 self.thing_type = Thing
809 def get_string_options(self, string_option_type):
810 if string_option_type == 'direction':
811 return self.world.map_.get_directions()
814 def send_gamestate(self, connection_id=None):
815 """Send out game state data relevant to clients."""
817 self.io.send('TURN ' + str(self.world.turn))
818 self.io.send('MAP ' + stringify_yx(self.world.map_.size))
819 visible_map = self.world.get_player().get_visible_map()
820 for y, line in visible_map.lines():
821 self.io.send('VISIBLE_MAP_LINE %5s %s' % (y, quote(line)))
822 visible_things = self.world.get_player().get_visible_things()
823 for thing in visible_things:
824 self.io.send('THING_TYPE %s %s' % (thing.id_, thing.type_))
825 self.io.send('THING_POS %s %s' % (thing.id_,
826 stringify_yx(thing.position)))
827 player = self.world.get_player()
828 self.io.send('PLAYER_POS %s' % (stringify_yx(player.position)))
829 self.io.send('GAME_STATE_COMPLETE')
832 """Send turn finish signal, run game world, send new world data.
834 First sends 'TURN_FINISHED' message, then runs game world
835 until new player input is needed, then sends game state.
837 self.io.send('TURN_FINISHED ' + str(self.world.turn))
838 self.world.proceed_to_next_player_turn()
839 msg = str(self.world.get_player()._last_task_result)
840 self.io.send('LAST_PLAYER_TASK_RESULT ' + quote(msg))
841 self.send_gamestate()
843 def get_command(self, command_name):
845 def partial_with_attrs(f, *args, **kwargs):
846 from functools import partial
847 p = partial(f, *args, **kwargs)
848 p.__dict__.update(f.__dict__)
851 def cmd_TASK_colon(task_name, game, *args):
852 game.world.get_player().set_task(task_name, args)
855 def cmd_SET_TASK_colon(task_name, game, thing_id, todo, *args):
856 t = game.world.get_thing(thing_id, False)
858 raiseArgError('No such Thing.')
859 task_class = game.tasks[task_name]
860 t.task = task_class(t, args)
863 def task_prefixed(command_name, task_prefix, task_command,
864 argtypes_prefix=None):
865 if command_name[:len(task_prefix)] == task_prefix:
866 task_name = command_name[len(task_prefix):]
867 if task_name in self.tasks:
868 f = partial_with_attrs(task_command, task_name, self)
869 task = self.tasks[task_name]
871 f.argtypes = argtypes_prefix + ' ' + task.argtypes
873 f.argtypes = task.argtypes
877 command = task_prefixed(command_name, 'TASK:', cmd_TASK_colon)
880 command = task_prefixed(command_name, 'SET_TASK:', cmd_SET_TASK_colon,
881 'int:nonneg int:nonneg ')
884 if command_name in self.commands:
885 f = partial_with_attrs(self.commands[command_name], self)
892 """Quote & escape string so client interprets it as single token."""
900 return ''.join(quoted)
903 def stringify_yx(tuple_):
904 """Transform tuple (y,x) into string 'Y:'+str(y)+',X:'+str(x)."""
905 return 'Y:' + str(tuple_[0]) + ',X:' + str(tuple_[1])