home · contact · privacy
e611a0ed825740cf9eff3bc74a2528738182236d
[plomrogue2-experiments] / server_ / game.py
1 import sys
2 sys.path.append('../')
3 import game_common
4 import parser
5
6
7 class GameError(Exception):
8     pass
9
10
11 def move_pos(direction, pos_yx):
12     if direction == 'UP':
13         pos_yx[0] -= 1
14     elif direction == 'DOWN':
15         pos_yx[0] += 1
16     elif direction == 'RIGHT':
17         pos_yx[1] += 1
18     elif direction == 'LEFT':
19         pos_yx[1] -= 1
20
21
22 class Map(game_common.Map):
23
24     def get_line(self, y):
25         width = self.size[1]
26         return self.terrain[y * width:(y + 1) * width]
27
28
29 class World(game_common.World):
30
31     def __init__(self):
32         super().__init__()
33         self.Thing = Thing  # use local Thing class instead of game_common's
34         self.map_ = Map()  # use extended child class
35         self.player_id = 0
36
37     def proceed_to_next_player_turn(self):
38         """Run game world turns until player can decide their next step.
39
40         Iterates through all non-player things, on each step
41         furthering them in their tasks (and letting them decide new
42         ones if they finish). The iteration order is: first all things
43         that come after the player in the world things list, then
44         (after incrementing the world turn) all that come before the
45         player; then the player's .proceed() is run, and if it does
46         not finish his task, the loop starts at the beginning. Once
47         the player's task is finished, the loop breaks.
48         """
49         while True:
50             player = self.get_player()
51             player_i = self.things.index(player)
52             for thing in self.things[player_i+1:]:
53                 thing.proceed()
54             self.turn += 1
55             for thing in self.things[:player_i]:
56                 thing.proceed()
57             player.proceed(is_AI=False)
58             if player.task is None:
59                 break
60
61     def get_player(self):
62         return self.get_thing(self.player_id)
63
64
65 class Task:
66
67     def __init__(self, thing, name, args=(), kwargs={}):
68         self.name = name
69         self.thing = thing
70         self.args = args
71         self.kwargs = kwargs
72         self.todo = 3
73
74     def check(self):
75         if self.name == 'move':
76             if len(self.args) > 0:
77                 direction = self.args[0]
78             else:
79                 direction = self.kwargs['direction']
80             test_pos = self.thing.position[:]
81             move_pos(direction, test_pos)
82             if test_pos[0] < 0 or test_pos[1] < 0 or \
83                test_pos[0] >= self.thing.world.map_.size[0] or \
84                test_pos[1] >= self.thing.world.map_.size[1]:
85                 raise GameError('would move outside map bounds')
86             pos_i = test_pos[0] * self.thing.world.map_.size[1] + test_pos[1]
87             map_tile = self.thing.world.map_.terrain[pos_i]
88             if map_tile != '.':
89                 raise GameError('would move into illegal terrain')
90             for t in self.thing.world.things:
91                 if t.position == test_pos:
92                     raise GameError('would move into other thing')
93
94
95 class Thing(game_common.Thing):
96
97     def __init__(self, *args, **kwargs):
98         super().__init__(*args, **kwargs)
99         self.task = Task(self, 'wait')
100         self.last_task_result = None
101         self._stencil = None
102
103     def task_wait(self):
104         return 'success'
105
106     def task_move(self, direction):
107         move_pos(direction, self.position)
108         return 'success'
109
110     def decide_task(self):
111         if self.position[1] > 1:
112             self.set_task('move', 'LEFT')
113         elif self.position[1] < 3:
114             self.set_task('move', 'RIGHT')
115         else:
116             self.set_task('wait')
117
118     def set_task(self, task_name, *args, **kwargs):
119         self.task = Task(self, task_name, args, kwargs)
120         self.task.check()
121
122     def proceed(self, is_AI=True):
123         """Further the thing in its tasks.
124
125         Decrements .task.todo; if it thus falls to <= 0, enacts method
126         whose name is 'task_' + self.task.name and sets .task =
127         None. If is_AI, calls .decide_task to decide a self.task.
128
129         Before doing anything, ensures an empty map visibility stencil
130         and checks that task is still possible, and aborts it
131         otherwise (for AI things, decides a new task).
132
133         """
134         self._stencil = None
135         try:
136             self.task.check()
137         except GameError as e:
138             self.task = None
139             self.last_task_result = e
140             if is_AI:
141                 self.decide_task()
142             return
143         self.task.todo -= 1
144         if self.task.todo <= 0:
145             task = getattr(self, 'task_' + self.task.name)
146             self.last_task_result = task(*self.task.args, **self.task.kwargs)
147             self.task = None
148         if is_AI and self.task is None:
149             self.decide_task()
150
151     def get_stencil(self):
152         if self._stencil is not None:
153             return self._stencil
154         size = self.world.map_.size
155         m = Map(self.world.map_.size, '?'*size[0]*size[1])
156         y_me = self.position[0]
157         x_me = self.position[1]
158         for y in range(m.size[0]):
159             if y in (y_me - 1, y_me, y_me + 1):
160                 for x in range(m.size[1]):
161                     if x in (x_me - 1, x_me, x_me + 1):
162                         pos = y * size[1] + x
163                         m.terrain = m.terrain[:pos] + '.' + m.terrain[pos+1:]
164         self._stencil = m
165         return self._stencil
166
167     def get_visible_map(self):
168         stencil = self.get_stencil()
169         size = self.world.map_.size
170         size_i = self.world.map_.size[0] * self.world.map_.size[1]
171         m = Map(size, ' '*size_i)
172         for i in range(size_i):
173             if stencil.terrain[i] == '.':
174                 c = self.world.map_.terrain[i]
175                 m.terrain = m.terrain[:i] + c + m.terrain[i+1:]
176         return m
177
178     def get_visible_things(self):
179         stencil = self.get_stencil()
180         visible_things = []
181         for thing in self.world.things:
182             width = self.world.map_.size[1]
183             pos_i = thing.position[0] * width + thing.position[1]
184             if stencil.terrain[pos_i] == '.':
185                 visible_things += [thing]
186         return visible_things
187
188
189 def fib(n):
190     """Calculate n-th Fibonacci number. Very inefficiently."""
191     if n in (1, 2):
192         return 1
193     else:
194         return fib(n-1) + fib(n-2)
195
196
197 class CommandHandler(game_common.Commander):
198
199     def __init__(self, game_file_name):
200         self.queues_out = {}
201         self.world = World()
202         self.parser = parser.Parser(self)
203         self.game_file_name = game_file_name
204         # self.pool and self.pool_result are currently only needed by the FIB
205         # command and the demo of a parallelized game loop in cmd_inc_p.
206         from multiprocessing import Pool
207         self.pool = Pool()
208         self.pool_result = None
209
210     def quote(self, string):
211         """Quote & escape string so client interprets it as single token."""
212         quoted = []
213         quoted += ['"']
214         for c in string:
215             if c in {'"', '\\'}:
216                 quoted += ['\\']
217             quoted += [c]
218         quoted += ['"']
219         return ''.join(quoted)
220
221     def handle_input(self, input_, connection_id=None, store=True):
222         """Process input_ to command grammar, call command handler if found."""
223         from inspect import signature
224
225         def answer(connection_id, msg):
226             if connection_id:
227                 self.send(msg, connection_id)
228             else:
229                 print(msg)
230
231         try:
232             command = self.parser.parse(input_)
233             if command is None:
234                 answer(connection_id, 'UNHANDLED_INPUT')
235             else:
236                 if 'connection_id' in list(signature(command).parameters):
237                     command(connection_id=connection_id)
238                 else:
239                     command()
240                     if store:
241                         with open(self.game_file_name, 'a') as f:
242                             f.write(input_ + '\n')
243         except parser.ArgError as e:
244             answer(connection_id, 'ARGUMENT_ERROR ' + self.quote(str(e)))
245         except game.GameError as e:
246             answer(connection_id, 'GAME_ERROR ' + self.quote(str(e)))
247
248     def send(self, msg, connection_id=None):
249         """Send message msg to server's client(s) via self.queues_out.
250
251         If a specific client is identified by connection_id, only
252         sends msg to that one. Else, sends it to all clients
253         identified in self.queues_out.
254
255         """
256         if connection_id:
257             self.queues_out[connection_id].put(msg)
258         else:
259             for connection_id in self.queues_out:
260                 self.queues_out[connection_id].put(msg)
261
262     def send_gamestate(self, connection_id=None):
263         """Send out game state data relevant to clients."""
264
265         def stringify_yx(tuple_):
266             """Transform tuple (y,x) into string 'Y:'+str(y)+',X:'+str(x)."""
267             return 'Y:' + str(tuple_[0]) + ',X:' + str(tuple_[1])
268
269         self.send('NEW_TURN ' + str(self.world.turn))
270         self.send('MAP_SIZE ' + stringify_yx(self.world.map_.size))
271         visible_map = self.world.get_player().get_visible_map()
272         for y in range(self.world.map_.size[0]):
273             self.send('VISIBLE_MAP_LINE %5s %s' %
274                       (y, self.quote(visible_map.get_line(y))))
275         visible_things = self.world.get_player().get_visible_things()
276         for thing in visible_things:
277             self.send('THING_TYPE %s %s' % (thing.id_, thing.type_))
278             self.send('THING_POS %s %s' % (thing.id_,
279                                            stringify_yx(thing.position)))
280
281     def proceed(self):
282         """Send turn finish signal, run game world, send new world data.
283
284         First sends 'TURN_FINISHED' message, then runs game world
285         until new player input is needed, then sends game state.
286         """
287         self.send('TURN_FINISHED ' + str(self.world.turn))
288         self.world.proceed_to_next_player_turn()
289         msg = str(self.world.get_player().last_task_result)
290         self.send('LAST_PLAYER_TASK_RESULT ' + self.quote(msg))
291         self.send_gamestate()
292
293     def cmd_FIB(self, numbers, connection_id):
294         """Reply with n-th Fibonacci numbers, n taken from tokens[1:].
295
296         Numbers are calculated in parallel as far as possible, using fib().
297         A 'CALCULATING …' message is sent to caller before the result.
298         """
299         self.send('CALCULATING …', connection_id)
300         results = self.pool.map(fib, numbers)
301         reply = ' '.join([str(r) for r in results])
302         self.send(reply, connection_id)
303     cmd_FIB.argtypes = 'seq:int:nonneg'
304
305     def cmd_INC_P(self, connection_id):
306         """Increment world.turn, send game turn data to everyone.
307
308         To simulate game processing waiting times, a one second delay between
309         TURN_FINISHED and NEW_TURN occurs; after NEW_TURN, some expensive
310         calculations are started as pool processes that need to be finished
311         until a further INC finishes the turn.
312
313         This is just a demo structure for how the game loop could work when
314         parallelized. One might imagine a two-step game turn, with a non-action
315         step determining actor tasks (the AI determinations would take the
316         place of the fib calculations here), and an action step wherein these
317         tasks are performed (where now sleep(1) is).
318         """
319         from time import sleep
320         if self.pool_result is not None:
321             self.pool_result.wait()
322         self.send('TURN_FINISHED ' + str(self.world.turn))
323         sleep(1)
324         self.world.turn += 1
325         self.send_gamestate()
326         self.pool_result = self.pool.map_async(fib, (35, 35))
327     def cmd_MOVE(self, direction):
328         """Set player task to 'move' with direction arg, finish player turn."""
329         if direction not in {'UP', 'DOWN', 'RIGHT', 'LEFT'}:
330             raise parser.ArgError('Move argument must be one of: '
331                                   'UP, DOWN, RIGHT, LEFT')
332         self.world.get_player().set_task('move', direction=direction)
333         self.proceed()
334     cmd_MOVE.argtypes = 'string'
335
336     def cmd_WAIT(self):
337         """Set player task to 'wait', finish player turn."""
338         self.world.get_player().set_task('wait')
339         self.proceed()
340
341     def cmd_GET_GAMESTATE(self, connection_id):
342         """Send game state jto caller."""
343         self.send_gamestate(connection_id)
344
345     def cmd_ECHO(self, msg, connection_id):
346         """Send msg to caller."""
347         self.send(msg, connection_id)
348     cmd_ECHO.argtypes = 'string'
349
350     def cmd_ALL(self, msg, connection_id):
351         """Send msg to all clients."""
352         self.send(msg)
353     cmd_ALL.argtypes = 'string'
354
355     def cmd_TERRAIN_LINE(self, y, terrain_line):
356         self.world.map_.set_line(y, terrain_line)
357     cmd_TERRAIN_LINE.argtypes = 'int:nonneg string'