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     def __iter__(self):
20         for y in range(self.size[0]):
21             for x in range(self.size[1]):
22                 yield [y, x]
23
24     @property
25     def size_i(self):
26         return self.size[0] * self.size[1]
27
28     def get_line(self, y):
29         width = self.size[1]
30         return self.terrain[y * width:(y + 1) * width]
31
32     def get_directions(self):
33         directions = []
34         for name in dir(self):
35             if name[:5] == 'move_':
36                 directions += [name[5:]]
37         return directions
38
39     def get_pos_i(self, yx):
40         return yx[0] * self.size[1] + yx[1]
41
42     def new_from_shape(self, init_char):
43         return Map(self.size, init_char*self.size_i)
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, direction)
121             if self.thing.world.map_[test_pos] != '.':
122                 raise GameError('would move into illegal terrain')
123             for t in self.thing.world.things:
124                 if t.position == test_pos:
125                     raise GameError('would move into other thing')
126
127
128 class Thing(game_common.Thing):
129
130     def __init__(self, *args, **kwargs):
131         super().__init__(*args, **kwargs)
132         self.task = Task(self, 'wait')
133         self.last_task_result = None
134         self._stencil = None
135
136     def task_wait(self):
137         return 'success'
138
139     def task_move(self, direction):
140         self.position = self.world.map_.move(self.position, direction)
141         return 'success'
142
143     def decide_task(self):
144         if self.position[1] > 1:
145             self.set_task('move', 'LEFT')
146         elif self.position[1] < 3:
147             self.set_task('move', 'RIGHT')
148         else:
149             self.set_task('wait')
150
151     def set_task(self, task_name, *args, **kwargs):
152         self.task = Task(self, task_name, args, kwargs)
153         self.task.check()
154
155     def proceed(self, is_AI=True):
156         """Further the thing in its tasks.
157
158         Decrements .task.todo; if it thus falls to <= 0, enacts method
159         whose name is 'task_' + self.task.name and sets .task =
160         None. If is_AI, calls .decide_task to decide a self.task.
161
162         Before doing anything, ensures an empty map visibility stencil
163         and checks that task is still possible, and aborts it
164         otherwise (for AI things, decides a new task).
165
166         """
167         self._stencil = None
168         try:
169             self.task.check()
170         except GameError as e:
171             self.task = None
172             self.last_task_result = e
173             if is_AI:
174                 self.decide_task()
175             return
176         self.task.todo -= 1
177         if self.task.todo <= 0:
178             task = getattr(self, 'task_' + self.task.name)
179             self.last_task_result = task(*self.task.args, **self.task.kwargs)
180             self.task = None
181         if is_AI and self.task is None:
182             self.decide_task()
183
184     def get_stencil(self):
185         if self._stencil is not None:
186             return self._stencil
187         m = self.world.map_.new_from_shape('?')
188         for pos in m:
189             if pos == self.position or m.are_neighbors(pos, self.position):
190                 m[pos] = '.'
191         self._stencil = m
192         return self._stencil
193
194     def get_visible_map(self):
195         stencil = self.get_stencil()
196         m = self.world.map_.new_from_shape(' ')
197         for pos in m:
198             if stencil[pos] == '.':
199                 m[pos] = self.world.map_[pos]
200         return m
201
202     def get_visible_things(self):
203         stencil = self.get_stencil()
204         visible_things = []
205         for thing in self.world.things:
206             if stencil[thing.position] == '.':
207                 visible_things += [thing]
208         return visible_things
209
210
211 def fib(n):
212     """Calculate n-th Fibonacci number. Very inefficiently."""
213     if n in (1, 2):
214         return 1
215     else:
216         return fib(n-1) + fib(n-2)
217
218
219 class Game(game_common.CommonCommandsMixin):
220
221     def __init__(self, game_file_name):
222         import server_.io
223         self.world = World()
224         self.io = server_.io.GameIO(game_file_name, self)
225         # self.pool and self.pool_result are currently only needed by the FIB
226         # command and the demo of a parallelized game loop in cmd_inc_p.
227         from multiprocessing import Pool
228         self.pool = Pool()
229         self.pool_result = None
230
231     def send_gamestate(self, connection_id=None):
232         """Send out game state data relevant to clients."""
233
234         def stringify_yx(tuple_):
235             """Transform tuple (y,x) into string 'Y:'+str(y)+',X:'+str(x)."""
236             return 'Y:' + str(tuple_[0]) + ',X:' + str(tuple_[1])
237
238         self.io.send('NEW_TURN ' + str(self.world.turn))
239         self.io.send('MAP_SIZE ' + stringify_yx(self.world.map_.size))
240         visible_map = self.world.get_player().get_visible_map()
241         for y in range(self.world.map_.size[0]):
242             self.io.send('VISIBLE_MAP_LINE %5s %s' %
243                          (y, self.io.quote(visible_map.get_line(y))))
244         visible_things = self.world.get_player().get_visible_things()
245         for thing in visible_things:
246             self.io.send('THING_TYPE %s %s' % (thing.id_, thing.type_))
247             self.io.send('THING_POS %s %s' % (thing.id_,
248                                               stringify_yx(thing.position)))
249
250     def proceed(self):
251         """Send turn finish signal, run game world, send new world data.
252
253         First sends 'TURN_FINISHED' message, then runs game world
254         until new player input is needed, then sends game state.
255         """
256         self.io.send('TURN_FINISHED ' + str(self.world.turn))
257         self.world.proceed_to_next_player_turn()
258         msg = str(self.world.get_player().last_task_result)
259         self.io.send('LAST_PLAYER_TASK_RESULT ' + self.io.quote(msg))
260         self.send_gamestate()
261
262     def cmd_FIB(self, numbers, connection_id):
263         """Reply with n-th Fibonacci numbers, n taken from tokens[1:].
264
265         Numbers are calculated in parallel as far as possible, using fib().
266         A 'CALCULATING …' message is sent to caller before the result.
267         """
268         self.io.send('CALCULATING …', connection_id)
269         results = self.pool.map(fib, numbers)
270         reply = ' '.join([str(r) for r in results])
271         self.io.send(reply, connection_id)
272     cmd_FIB.argtypes = 'seq:int:nonneg'
273
274     def cmd_INC_P(self, connection_id):
275         """Increment world.turn, send game turn data to everyone.
276
277         To simulate game processing waiting times, a one second delay between
278         TURN_FINISHED and NEW_TURN occurs; after NEW_TURN, some expensive
279         calculations are started as pool processes that need to be finished
280         until a further INC finishes the turn.
281
282         This is just a demo structure for how the game loop could work when
283         parallelized. One might imagine a two-step game turn, with a non-action
284         step determining actor tasks (the AI determinations would take the
285         place of the fib calculations here), and an action step wherein these
286         tasks are performed (where now sleep(1) is).
287         """
288         from time import sleep
289         if self.pool_result is not None:
290             self.pool_result.wait()
291         self.io.send('TURN_FINISHED ' + str(self.world.turn))
292         sleep(1)
293         self.world.turn += 1
294         self.send_gamestate()
295         self.pool_result = self.pool.map_async(fib, (35, 35))
296
297     def cmd_MOVE(self, direction):
298         """Set player task to 'move' with direction arg, finish player turn."""
299         import parser
300         legal_directions = self.world.map_.get_directions()
301         if direction not in legal_directions:
302             raise parser.ArgError('Move argument must be one of: ' +
303                                   ', '.join(legal_directions))
304         self.world.get_player().set_task('move', direction=direction)
305         self.proceed()
306     cmd_MOVE.argtypes = 'string'
307
308     def cmd_WAIT(self):
309         """Set player task to 'wait', finish player turn."""
310         self.world.get_player().set_task('wait')
311         self.proceed()
312
313     def cmd_GET_GAMESTATE(self, connection_id):
314         """Send game state jto caller."""
315         self.send_gamestate(connection_id)
316
317     def cmd_ECHO(self, msg, connection_id):
318         """Send msg to caller."""
319         self.io.send(msg, connection_id)
320     cmd_ECHO.argtypes = 'string'
321
322     def cmd_ALL(self, msg, connection_id):
323         """Send msg to all clients."""
324         self.io.send(msg)
325     cmd_ALL.argtypes = 'string'
326
327     def cmd_TERRAIN_LINE(self, y, terrain_line):
328         self.world.map_.set_line(y, terrain_line)
329     cmd_TERRAIN_LINE.argtypes = 'int:nonneg string'