home · contact · privacy
Minor code rearrangements.
[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 send(self, msg, connection_id=None):
211         """Send message msg to server's client(s) via self.queues_out.
212
213         If a specific client is identified by connection_id, only
214         sends msg to that one. Else, sends it to all clients
215         identified in self.queues_out.
216
217         """
218         if connection_id:
219             self.queues_out[connection_id].put(msg)
220         else:
221             for connection_id in self.queues_out:
222                 self.queues_out[connection_id].put(msg)
223
224     def handle_input(self, input_, connection_id=None, store=True):
225         """Process input_ to command grammar, call command handler if found."""
226         from inspect import signature
227
228         def answer(connection_id, msg):
229             if connection_id:
230                 self.send(msg, connection_id)
231             else:
232                 print(msg)
233
234         try:
235             command = self.parser.parse(input_)
236             if command is None:
237                 answer(connection_id, 'UNHANDLED_INPUT')
238             else:
239                 if 'connection_id' in list(signature(command).parameters):
240                     command(connection_id=connection_id)
241                 else:
242                     command()
243                     if store:
244                         with open(self.game_file_name, 'a') as f:
245                             f.write(input_ + '\n')
246         except parser.ArgError as e:
247             answer(connection_id, 'ARGUMENT_ERROR ' + self.quote(str(e)))
248         except game.GameError as e:
249             answer(connection_id, 'GAME_ERROR ' + self.quote(str(e)))
250
251     def quote(self, string):
252         """Quote & escape string so client interprets it as single token."""
253         quoted = []
254         quoted += ['"']
255         for c in string:
256             if c in {'"', '\\'}:
257                 quoted += ['\\']
258             quoted += [c]
259         quoted += ['"']
260         return ''.join(quoted)
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
328     def cmd_MOVE(self, direction):
329         """Set player task to 'move' with direction arg, finish player turn."""
330         if direction not in {'UP', 'DOWN', 'RIGHT', 'LEFT'}:
331             raise parser.ArgError('Move argument must be one of: '
332                                   'UP, DOWN, RIGHT, LEFT')
333         self.world.get_player().set_task('move', direction=direction)
334         self.proceed()
335     cmd_MOVE.argtypes = 'string'
336
337     def cmd_WAIT(self):
338         """Set player task to 'wait', finish player turn."""
339         self.world.get_player().set_task('wait')
340         self.proceed()
341
342     def cmd_GET_GAMESTATE(self, connection_id):
343         """Send game state jto caller."""
344         self.send_gamestate(connection_id)
345
346     def cmd_ECHO(self, msg, connection_id):
347         """Send msg to caller."""
348         self.send(msg, connection_id)
349     cmd_ECHO.argtypes = 'string'
350
351     def cmd_ALL(self, msg, connection_id):
352         """Send msg to all clients."""
353         self.send(msg)
354     cmd_ALL.argtypes = 'string'
355
356     def cmd_TERRAIN_LINE(self, y, terrain_line):
357         self.world.map_.set_line(y, terrain_line)
358     cmd_TERRAIN_LINE.argtypes = 'int:nonneg string'