home · contact · privacy
New structure.
[plomrogue2-experiments] / new / plomrogue2.py
1 #!/usr/bin/env python3
2 import socketserver
3 import threading
4 import queue
5 import sys
6 import parser
7
8
9 class GameError(Exception):
10     pass
11
12
13 # Avoid "Address already in use" errors.
14 socketserver.TCPServer.allow_reuse_address = True
15
16
17 class Server(socketserver.ThreadingTCPServer):
18     """Bind together threaded IO handling server and message queue."""
19
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.
24
25
26 class IO_Handler(socketserver.BaseRequestHandler):
27
28     def handle(self):
29         """Move messages between network socket and game IO loop via queues.
30
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.
35
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.
41
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
48         instructions.
49
50         """
51
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]:
55                 try:
56                     msg = queue_in.get(timeout=1)
57                 except queue.Empty:
58                     continue
59                 plom_socket.send(msg, True)
60
61         import uuid
62         import plom_socket
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))
68         thread_alive = [True]
69         t = threading.Thread(target=send_queue_messages,
70                              args=(plom_socket, queue_in, thread_alive))
71         t.start()
72         for message in plom_socket.recv():
73             if message is None:
74                 plom_socket.send('BAD MESSAGE', True)
75             elif 'QUIT' == message:
76                 plom_socket.send('BYE', True)
77                 break
78             else:
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()
84
85
86 class GameIO():
87
88     def __init__(self, game_file_name, game):
89         self.game_file_name = game_file_name
90         self.queues_out = {}
91         self.parser = parser.Parser(game)
92
93     def loop(self, q):
94         """Handle commands coming through queue q, send results back.
95
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.
100
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.
105
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.
109
110         """
111         while True:
112             x = q.get()
113             command_type = x[0]
114             connection_id = x[1]
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)
122
123     def run_loop_with_server(self):
124         """Run connection of server talking to clients and game IO loop.
125
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.
134
135         """
136         q = queue.Queue()
137         c = threading.Thread(target=self.loop, daemon=True, args=(q,))
138         c.start()
139         server = Server(q, 5000)
140         try:
141             server.serve_forever()
142         except KeyboardInterrupt:
143             pass
144         finally:
145             print('Killing server')
146             server.server_close()
147
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
151
152         def answer(connection_id, msg):
153             if connection_id:
154                 self.send(msg, connection_id)
155             else:
156                 print(msg)
157
158         try:
159             command, args = self.parser.parse(input_)
160             if command is None:
161                 answer(connection_id, 'UNHANDLED_INPUT')
162             else:
163                 if 'connection_id' in list(signature(command).parameters):
164                     command(*args, connection_id=connection_id)
165                 else:
166                     command(*args)
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)))
174
175     def send(self, msg, connection_id=None):
176         """Send message msg to server's client(s) via self.queues_out.
177
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.
181
182         """
183         if connection_id:
184             self.queues_out[connection_id].put(msg)
185         else:
186             for connection_id in self.queues_out:
187                 self.queues_out[connection_id].put(msg)
188
189
190 class MapBase:
191
192     def __init__(self, size=(0, 0)):
193         self.size = size
194         self.terrain = '?'*self.size_i
195
196     @property
197     def size_i(self):
198         return self.size[0] * self.size[1]
199
200     def set_line(self, y, line):
201         height_map = self.size[0]
202         width_map = self.size[1]
203         if y >= height_map:
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:]
210
211     def get_position_index(self, yx):
212         return yx[0] * self.size[1] + yx[1]
213
214
215 class Map(MapBase):
216
217     def __getitem__(self, yx):
218         return self.terrain[self.get_position_index(yx)]
219
220     def __setitem__(self, yx, c):
221         pos_i = self.get_position_index(yx)
222         if type(c) == str:
223             self.terrain = self.terrain[:pos_i] + c + self.terrain[pos_i + 1:]
224         else:
225             self.terrain[pos_i] = c
226
227     def __iter__(self):
228         """Iterate over YX position coordinates."""
229         for y in range(self.size[0]):
230             for x in range(self.size[1]):
231                 yield [y, x]
232
233     def lines(self):
234         width = self.size[1]
235         for y in range(self.size[0]):
236             yield (y, self.terrain[y * width:(y + 1) * width])
237
238     def get_fov_map(self, yx):
239         return self.fov_map_type(self, yx)
240
241     def get_directions(self):
242         directions = []
243         for name in dir(self):
244             if name[:5] == 'move_':
245                 directions += [name[5:]]
246         return directions
247
248     def get_neighbors(self, pos):
249         neighbors = {}
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
256             try:
257                 neighbors[direction] = self.move(pos, direction)
258             except GameError:
259                 pass
260         self.neighbors_to[pos] = neighbors
261         return neighbors
262
263     def new_from_shape(self, init_char):
264         import copy
265         new_map = copy.deepcopy(self)
266         for pos in new_map:
267             new_map[pos] = init_char
268         return new_map
269
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')
276         return new_pos
277
278     def move_LEFT(self, start_pos):
279         return [start_pos[0], start_pos[1] - 1]
280
281     def move_RIGHT(self, start_pos):
282         return [start_pos[0], start_pos[1] + 1]
283
284
285
286 class MapHex(Map):
287
288     def __init__(self, *args, **kwargs):
289         super().__init__(*args, **kwargs)
290         self.fov_map_type = FovMapHex
291
292     def move_UPLEFT(self, start_pos):
293         if start_pos[0] % 2 == 1:
294             return [start_pos[0] - 1, start_pos[1] - 1]
295         else:
296             return [start_pos[0] - 1, start_pos[1]]
297
298     def move_UPRIGHT(self, start_pos):
299         if start_pos[0] % 2 == 1:
300             return [start_pos[0] - 1, start_pos[1]]
301         else:
302             return [start_pos[0] - 1, start_pos[1] + 1]
303
304     def move_DOWNLEFT(self, start_pos):
305         if start_pos[0] % 2 == 1:
306              return [start_pos[0] + 1, start_pos[1] - 1]
307         else:
308                return [start_pos[0] + 1, start_pos[1]]
309
310     def move_DOWNRIGHT(self, start_pos):
311         if start_pos[0] % 2 == 1:
312             return [start_pos[0] + 1, start_pos[1]]
313         else:
314             return [start_pos[0] + 1, start_pos[1] + 1]
315
316
317
318 class FovMap:
319
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
324         self[yx] = '.'
325         self.shadow_cones = []
326         self.circle_out(yx, self.shadow_process_hex)
327
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] == '.',
330         # skip all.
331         CIRCLE = 360  # Since we'll float anyways, number is actually arbitrary.
332
333         def correct_arm(arm):
334             if arm < 0:
335                 arm += CIRCLE
336             return arm
337
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)
343                     return True
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.
348             return False
349
350         def merge_cone(new_cone):
351             import math
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)
359                     return True
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)
366                     return True
367             return False
368
369         def eval_cone(cone):
370             #print('DEBUG CONE', cone, '(', step_size, distance_to_center, number_steps, ')')
371             if in_shadow_cone(cone):
372                 return
373             self[yx] = '.'
374             if self.source_map[yx] != '.':
375                 #print('DEBUG throws shadow', cone)
376                 unmerged = True
377                 while merge_cone(cone):
378                     unmerged = False
379                 if unmerged:
380                     self.shadow_cones += [cone]
381
382         #print('DEBUG', yx)
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])
392         else:
393             eval_cone([left_arm, right_arm])
394
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)
398         pos[:] = mover(pos)
399         if pos[0] < 0 or pos[1] < 0 or \
400             pos[0] >= self.size[0] or pos[1] >= self.size[1]:
401             return False
402         return True
403
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.)
411
412         # TODO: Start circling only in earliest obstacle distance.
413         circle_in_map = True
414         distance = 1
415         yx = yx[:]
416         #print('DEBUG CIRCLE_OUT', yx)
417         while circle_in_map:
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)
425                         circle_in_map = True
426             distance += 1
427
428
429
430 class FovMapHex(FovMap, MapHex):
431     circle_out_directions = ('DOWNLEFT', 'LEFT', 'UPLEFT',
432                              'UPRIGHT', 'RIGHT', 'DOWNRIGHT')
433
434     def circle_out_move(self, yx, direction):
435         return self.basic_circle_out_move(yx, direction)
436
437
438
439 class ThingBase:
440
441     def __init__(self, world, id_, type_='?', position=[0,0]):
442         self.world = world
443         self.id_ = id_
444         self.type_ = type_
445         self.position = position
446
447
448 class Thing(ThingBase):
449
450     def __init__(self, *args, **kwargs):
451         super().__init__(*args, **kwargs)
452         self.set_task('WAIT')
453         self._last_task_result = None
454         self._stencil = None
455
456     def move_towards_target(self, target):
457         dijkstra_map = type(self.world.map_)(self.world.map_.size)
458         n_max = 256
459         dijkstra_map.terrain = [n_max for i in range(dijkstra_map.size_i)]
460         dijkstra_map[target] = 0
461         shrunk = True
462         visible_map = self.get_visible_map()
463         while shrunk:
464             shrunk = False
465             for pos in dijkstra_map:
466                 if visible_map[pos] != '.':
467                     continue
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
473                         shrunk = True
474         #with open('log', 'a') as f:
475         #    f.write('---------------------------------\n')
476         #    for y, line in dijkstra_map.lines():
477         #        for val in line:
478         #            if val < 10:
479         #                f.write(str(val))
480         #            elif val == 256:
481         #                f.write('x')
482         #            else:
483         #                f.write('~')
484         #        f.write('\n')
485         neighbors = dijkstra_map.get_neighbors(tuple(self.position))
486         n = n_max
487         #print('DEBUG', self.position, neighbors)
488         #dirs = dijkstra_map.get_directions()
489         #print('DEBUG dirs', dirs)
490         #print('DEBUG neighbors', neighbors)
491         #debug_scores = []
492         #for pos in neighbors:
493         #    if pos is None:
494         #        debug_scores += [9000]
495         #    else:
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]
501             if yx is not None:
502                 n_new = dijkstra_map[yx]
503                 if n_new < n:
504                     n = n_new
505                     target_direction = direction
506         #print('DEBUG result', direction)
507         if target_direction:
508             self.set_task('MOVE', (target_direction,))
509
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()
513         target = None
514         for t in visible_things:
515             if t.type_ == 'human':
516                 target = t.position
517                 break
518         if target is not None:
519             try:
520                 self.move_towards_target(target)
521                 return
522             except GameError:
523                 pass
524         self.set_task('WAIT')
525
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
530
531     def proceed(self, is_AI=True):
532         """Further the thing in its tasks.
533
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.
537
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).
541
542         """
543         self._stencil = None
544         try:
545             self.task.check()
546         except GameError as e:
547             self.task = None
548             self._last_task_result = e
549             if is_AI:
550                 try:
551                     self.decide_task()
552                 except GameError:
553                     self.set_task('WAIT')
554             return
555         self.task.todo -= 1
556         if self.task.todo <= 0:
557             self._last_task_result = self.task.do()
558             self.task = None
559         if is_AI and self.task is None:
560             try:
561                 self.decide_task()
562             except GameError:
563                 self.set_task('WAIT')
564
565     def get_stencil(self):
566         if self._stencil is not None:
567             return self._stencil
568         self._stencil = self.world.map_.get_fov_map(self.position)
569         return self._stencil
570
571     def get_visible_map(self):
572         stencil = self.get_stencil()
573         m = self.world.map_.new_from_shape(' ')
574         for pos in m:
575             if stencil[pos] == '.':
576                 m[pos] = self.world.map_[pos]
577         return m
578
579     def get_visible_things(self):
580         stencil = self.get_stencil()
581         visible_things = []
582         for thing in self.world.things:
583             if stencil[thing.position] == '.':
584                 visible_things += [thing]
585         return visible_things
586
587
588
589 class Task:
590     argtypes = ''
591
592     def __init__(self, thing, args=()):
593         self.thing = thing
594         self.args = args
595         self.todo = 3
596
597     @property
598     def name(self):
599         prefix = 'Task_'
600         class_name = self.__class__.__name__
601         return class_name[len(prefix):]
602
603     def check(self):
604         pass
605
606     def get_args_string(self):
607         stringed_args = []
608         for arg in self.args:
609             if type(arg) == str:
610                 stringed_args += [quote(arg)]
611             else:
612                 raise GameError('stringifying arg type not implemented')
613         return ' '.join(stringed_args)
614
615
616
617 class Task_WAIT(Task):
618
619     def do(self):
620         return 'success'
621
622
623
624 class Task_MOVE(Task):
625     argtypes = 'string:direction'
626
627     def check(self):
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_)
634
635     def do(self):
636         self.thing.position = self.thing.world.map_.move(self.thing.position,
637                                                          self.args[0])
638
639
640
641 class WorldBase:
642
643     def __init__(self, game):
644         self.turn = 0
645         self.things = []
646         self.game = game
647
648     def get_thing(self, id_, create_unfound=True):
649         for thing in self.things:
650             if id_ == thing.id_:
651                 return thing
652         if create_unfound:
653             t = self.game.thing_type(self, id_)
654             self.things += [t]
655             return t
656         return None
657
658
659 class World(WorldBase):
660
661     def __init__(self, *args, **kwargs):
662         super().__init__(*args, **kwargs)
663         self.player_id = 0
664
665     def new_map(self, yx):
666         self.map_ = self.game.map_type(yx)
667
668     def proceed_to_next_player_turn(self):
669         """Run game world turns until player can decide their next step.
670
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.
679         """
680         while True:
681             player = self.get_player()
682             player_i = self.things.index(player)
683             for thing in self.things[player_i+1:]:
684                 thing.proceed()
685             self.turn += 1
686             for thing in self.things[:player_i]:
687                 thing.proceed()
688             player.proceed(is_AI=False)
689             if player.task is None:
690                 break
691
692     def get_player(self):
693         return self.get_thing(self.player_id)
694
695     def make_new(self, yx, seed):
696         import random
697         random.seed(seed)
698         self.turn = 0
699         self.new_map(yx)
700         for pos in self.map_:
701             if 0 in pos or (yx[0] - 1) == pos[0] or (yx[1] - 1) == pos[1]:
702                 self.map_[pos] = '#'
703                 continue
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]
714         return 'success'
715
716
717
718 def cmd_GEN_WORLD(self, yx, seed):
719     self.world.make_new(yx, seed)
720 cmd_GEN_WORLD.argtypes = 'yx_tuple:pos string'
721
722 def cmd_GET_GAMESTATE(self, connection_id):
723     """Send game state to caller."""
724     self.send_gamestate(connection_id)
725
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'
730
731 def cmd_THING_TYPE(self, i, type_):
732     t = self.world.get_thing(i)
733     t.type_ = type_
734 cmd_THING_TYPE.argtypes = 'int:nonneg string'
735
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'
740
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'
744
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'
749
750 def cmd_TURN(self, n):
751     self.world.turn = n
752 cmd_TURN.argtypes = 'int:nonneg'
753
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]
761     else:
762         self.world.player_id = thing_ids[player_index + 1]
763     self.proceed()
764
765 def cmd_SAVE(self):
766
767     def write(f, msg):
768         f.write(msg + '\n')
769
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)))
780             task = thing.task
781             if task is not None:
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
787
788
789 class Game:
790
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,
797                          'MAP': cmd_MAP,
798                          'THING_TYPE': cmd_THING_TYPE,
799                          'THING_POS': cmd_THING_POS,
800                          'TERRAIN_LINE': cmd_TERRAIN_LINE,
801                          'PLAYER_ID': cmd_PLAYER_ID,
802                          'TURN': cmd_TURN,
803                          'SWITCH_PLAYER': cmd_SWITCH_PLAYER,
804                          'SAVE': cmd_SAVE}
805         self.world_type = World
806         self.world = self.world_type(self)
807         self.thing_type = Thing
808
809     def get_string_options(self, string_option_type):
810         if string_option_type == 'direction':
811             return self.world.map_.get_directions()
812         return None
813
814     def send_gamestate(self, connection_id=None):
815         """Send out game state data relevant to clients."""
816
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')
830
831     def proceed(self):
832         """Send turn finish signal, run game world, send new world data.
833
834         First sends 'TURN_FINISHED' message, then runs game world
835         until new player input is needed, then sends game state.
836         """
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()
842
843     def get_command(self, command_name):
844         from functools import partial
845
846         def cmd_TASK_colon(task_name, game, *args):
847             game.world.get_player().set_task(task_name, args)
848             game.proceed()
849
850         def cmd_SET_TASK_colon(task_name, game, thing_id, todo, *args):
851             t = game.world.get_thing(thing_id, False)
852             if t is None:
853                 raiseArgError('No such Thing.')
854             task_class = game.tasks[task_name]
855             t.task = task_class(t, args)
856             t.task.todo = todo
857
858         def task_prefixed(command_name, task_prefix, task_command,
859                           argtypes_prefix=None):
860             if command_name[:len(task_prefix)] == task_prefix:
861                 task_name = command_name[len(task_prefix):]
862                 if task_name in self.tasks:
863                     f = partial(task_command, task_name, self)
864                     task = self.tasks[task_name]
865                     if argtypes_prefix:
866                         f.argtypes = argtypes_prefix + ' ' + task.argtypes
867                     else:
868                         f.argtypes = task.argtypes
869                     return f
870             return None
871
872         command = task_prefixed(command_name, 'TASK:', cmd_TASK_colon)
873         if command:
874             return command
875         command = task_prefixed(command_name, 'SET_TASK:', cmd_SET_TASK_colon,
876                                 'int:nonneg int:nonneg ')
877         if command:
878             return command
879         if command_name in self.commands:
880             f = partial(self.commands[command_name], self)
881             if hasattr(self.commands[command_name], 'argtypes'):
882                 f.argtypes = self.commands[command_name].argtypes
883             return f
884         return None
885
886
887
888 def quote(string):
889     """Quote & escape string so client interprets it as single token."""
890     quoted = []
891     quoted += ['"']
892     for c in string:
893         if c in {'"', '\\'}:
894             quoted += ['\\']
895         quoted += [c]
896     quoted += ['"']
897     return ''.join(quoted)
898
899
900 def stringify_yx(tuple_):
901     """Transform tuple (y,x) into string 'Y:'+str(y)+',X:'+str(x)."""
902     return 'Y:' + str(tuple_[0]) + ',X:' + str(tuple_[1])
903
904
905
906 if __name__ == "__main__":
907     import sys
908     import os
909     if len(sys.argv) != 2:
910         print('wrong number of arguments, expected one (game file)')
911         exit(1)
912     game_file_name = sys.argv[1]
913     game = Game(game_file_name)
914     if os.path.exists(game_file_name):
915         if not os.path.isfile(game_file_name):
916             print('game file name does not refer to a valid game file')
917         else:
918             with open(game_file_name, 'r') as f:
919                 lines = f.readlines()
920             for i in range(len(lines)):
921                 line = lines[i]
922                 print("FILE INPUT LINE %5s: %s" % (i, line), end='')
923                 game.io.handle_input(line, store=False)
924     else:
925         game.io.handle_input('GEN_WORLD Y:16,X:16 bar')
926     game.io.run_loop_with_server()