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