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