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