Package horizons :: Module manager
[hide private]
[frames] | no frames]

Source Code for Module horizons.manager

  1  # ################################################### 
  2  # Copyright (C) 2008-2017 The Unknown Horizons Team 
  3  # team@unknown-horizons.org 
  4  # This file is part of Unknown Horizons. 
  5  # 
  6  # Unknown Horizons is free software; you can redistribute it and/or modify 
  7  # it under the terms of the GNU General Public License as published by 
  8  # the Free Software Foundation; either version 2 of the License, or 
  9  # (at your option) any later version. 
 10  # 
 11  # This program is distributed in the hope that it will be useful, 
 12  # but WITHOUT ANY WARRANTY; without even the implied warranty of 
 13  # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the 
 14  # GNU General Public License for more details. 
 15  # 
 16  # You should have received a copy of the GNU General Public License 
 17  # along with this program; if not, write to the 
 18  # Free Software Foundation, Inc., 
 19  # 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA 
 20  # ################################################### 
 21   
 22  import itertools 
 23  import logging 
 24  import operator 
 25   
 26  from horizons.command.building import Build 
 27  from horizons.i18n import gettext as T 
 28  from horizons.network import CommandError, packets 
 29  from horizons.scheduler import Scheduler 
 30  from horizons.timer import Timer 
 31  from horizons.util.living import LivingObject 
 32  from horizons.util.worldobject import WorldObject 
33 34 35 -class SPManager(LivingObject):
36 """The manager class takes care of command issuing to the timermanager, sends tick-packets 37 over the network, and synchronization of network games.""" 38
39 - def __init__(self, session):
40 super().__init__() 41 self.session = session 42 self.commands = []
43
44 - def execute(self, command, local=False):
45 """Executes a command 46 @param command: Command the command to be executed 47 @param local: Whether to only execute command here (doesn't make sense in singleplayer 48 """ 49 # if we are in demo playback mode, every incoming command has to be thrown away. 50 if self.commands: 51 return 52 ret = command(issuer=self.session.world.player) # actually execute the command 53 # some commands might have a return value, so forward it 54 return ret
55
56 - def load(self, db):
57 pass
58
59 - def end(self):
60 self.commands = None 61 super().end()
62
63 64 -class MPManager(LivingObject):
65 """Handler for commands. 66 Initiates sending commands over the network for multiplayer games and their correct 67 execution time and is also responsible for handling lags""" 68 log = logging.getLogger("mpmanager") 69 command_log = logging.getLogger("mpmanager.commands") # command executions 70 EXECUTIONDELAY = 4 71 HASHDELAY = 4 72 HASH_EVAL_DISTANCE = 2 # interval, check hash every nth tick 73
74 - def __init__(self, session, networkinterface):
75 """Initialize the Multiplayer Manager""" 76 super().__init__() 77 self.session = session 78 self.networkinterface = networkinterface 79 self.commandsmanager = MPCommandsManager(self) 80 self.localcommandsmanager = MPCommandsManager(self) 81 self.checkuphashmanager = MPCheckupHashManager(self) 82 self.gamecommands = [] # commands from the local user, that will be part of next CommandPacket 83 self.localcommands = [] # (only local) commands from the local user (e.g. sounds only this user should hear) 84 85 self.session.timer.add_test(self.can_tick) 86 self.session.timer.add_call(self.tick) 87 88 self.session.timer.add_test(self.can_hash_value_check) 89 self.session.timer.add_call(self.hash_value_check) 90 91 self._last_local_commands_send_tick = -1 # last tick, where local commands got sent
92
93 - def end(self):
94 pass
95
96 - def can_tick(self, tick):
97 """Checks if we can execute this tick via return value""" 98 # get new packages fom networkinteface 99 packets_received = None 100 try: 101 packets_received = self.networkinterface.receive_all() 102 except CommandError: 103 return Timer.TEST_SKIP 104 105 for packet in packets_received: 106 if isinstance(packet, CommandPacket): 107 self.log.debug("Got command packet from " + str(packet.player_id) + " for tick " + str(packet.tick)) 108 self.commandsmanager.add_packet(packet) 109 elif isinstance(packet, CheckupHashPacket): 110 self.log.debug("Got checkuphash packet from " + str(packet.player_id) + " for tick " + str(packet.tick)) 111 self.checkuphashmanager.add_packet(packet) 112 else: 113 self.log.warning("invalid packet: " + str(packet)) 114 115 # send out new commands 116 # check if we already sent commands for this tick (only 1 packet per tick is allowed, 117 # in case of lags this code would be executed multiple times for the same tick) 118 if self._last_local_commands_send_tick < tick: 119 self._last_local_commands_send_tick = tick 120 commandpacket = CommandPacket(self.calculate_execution_tick(tick), 121 self.session.world.player.worldid, self.gamecommands) 122 self.gamecommands = [] 123 self.commandsmanager.add_packet(commandpacket) 124 self.log.debug("sending command for tick %d", commandpacket.tick) 125 self.networkinterface.send_packet(commandpacket) 126 127 self.localcommandsmanager.add_packet(CommandPacket(self.calculate_execution_tick(tick), 128 self.session.world.player.worldid, self.localcommands)) 129 self.localcommands = [] 130 131 # check if we have to evaluate a hash value 132 if self.calculate_hash_tick(tick) % self.HASH_EVAL_DISTANCE == 0: 133 hash_value = self.session.world.get_checkup_hash() 134 #self.log.debug("MPManager: Checkup hash for tick %s is %s", tick, hash_value) 135 checkuphashpacket = CheckupHashPacket(self.calculate_hash_tick(tick), 136 self.session.world.player.worldid, hash_value) 137 self.checkuphashmanager.add_packet(checkuphashpacket) 138 self.log.debug("sending checkuphash for tick %d", checkuphashpacket.tick) 139 self.networkinterface.send_packet(checkuphashpacket) 140 141 # decide if tick can be calculated 142 # in the first few ticks, no data is available 143 if self.commandsmanager.is_tick_ready(tick) or tick < (Scheduler.FIRST_TICK_ID + self.EXECUTIONDELAY): 144 #self.log.debug("MPManager: check tick %s ready: yes", tick) 145 return Timer.TEST_PASS 146 else: 147 self.log.debug("MPManager: check tick %s ready: no", tick) 148 return Timer.TEST_SKIP
149
150 - def tick(self, tick):
151 """Do the tick (execute all commands for this tick) 152 This code may only be reached if we are allowed to tick now (@see can_tick)""" 153 # calculate command packets for this tick 154 command_packets = self.commandsmanager.get_packets_for_tick(tick) 155 command_packets.extend(self.localcommandsmanager.get_packets_for_tick(tick)) 156 # sort by player, so that the packets get executed in the same order in every client 157 # (packets are already in a special order within the packets, so no further sorting is necessary) 158 command_packets.sort(key=operator.attrgetter('player_id')) 159 160 for command_packet in command_packets: 161 for command in command_packet.commandlist: 162 self.log.debug("MPManager: calling command (tick %s): %s", tick, command) 163 self.command_log.debug("MPManagerCommand: (tick %s): %s", tick, command) 164 command(WorldObject.get_object_by_id(command_packet.player_id))
165
166 - def can_hash_value_check(self, tick):
167 if self.checkuphashmanager.is_tick_ready(tick) or tick < self.HASHDELAY: 168 return Timer.TEST_PASS 169 else: 170 return Timer.TEST_SKIP
171
172 - def hash_value_check(self, tick):
173 if tick % self.HASH_EVAL_DISTANCE == 0: 174 if not self.checkuphashmanager.are_checkup_hash_values_equal(tick, self.hash_value_diff): 175 self.log.error("MPManager: Hash values generated in tick %s are not equal", 176 str(tick - self.HASHDELAY)) 177 # if this is reached, we are screwed. Something went wrong in the simulation, 178 # but we don't know what. Stop the game. 179 msg = T("The games have run out of sync. This indicates an unknown internal error, the game cannot continue.") + "\n" + \ 180 T("We are very sorry and hope to have this bug fixed in a future version.") 181 self.session.ingame_gui.open_error_popup('Out of sync', msg)
182
183 - def hash_value_diff(self, player1, hash1, player2, hash2):
184 """Called when a divergence has been detected""" 185 self.log.error("MPManager: Hash diff:\n%s hash1: %s\n%s hash2: %s", player1, hash1, player2, hash2) 186 self.log.error("------------------") 187 self.log.error("Differences:") 188 if len(hash1) != len(hash2): 189 self.log.error("Different length") 190 items1 = sorted(hash1.items()) 191 items2 = sorted(hash2.items()) 192 for i in range(min(len(hash1), len(hash2))): 193 if (items1[i] != items2[i]): 194 self.log.error(str(i) + ": " + str(items1[i])) 195 self.log.error(str(i) + ": " + str(items2[i])) 196 self.log.error("------------------")
197
198 - def calculate_execution_tick(self, tick):
199 return tick + self.EXECUTIONDELAY
200
201 - def calculate_hash_tick(self, tick):
202 return tick + self.HASHDELAY
203
204 - def execute(self, command, local=False):
205 """Receive commands to be executed from local player 206 @param command: Command instance 207 @param local: commands that don't need to be sent over the wire""" 208 self.log.debug('MPManager: adding command (next tick: %d) %s', self.session.timer.tick_next_id, str(command)) 209 if local: 210 self.localcommands.append(command) 211 else: 212 self.gamecommands.append(command)
213
214 - def get_player_count(self):
215 return len(self.session.world.players)
216
218 """Returns all Build-commands by the local player, that are executed in the next ticks""" 219 commandpackets = self.commandsmanager.get_packets_from_player(self.session.world.player.worldid) 220 221 # check commands already sent 222 l1 = itertools.chain.from_iterable(pkg.commandlist for pkg in commandpackets) 223 # and the ones that haven't been sent yet (this are of course only commands by the local player) 224 commandlist = itertools.chain(l1, self.gamecommands) 225 226 return [x for x in commandlist if isinstance(x, Build)]
227
228 - def load(self, db):
229 """Execute outstanding commands, loaded from db. 230 Currently not supported for MP""" 231 # NOTE: it is supported now, and such outstanding commands are dropped right now 232 pass
233
234 # Packagemanagers storing Packages for later use 235 ################################################ 236 237 238 -class MPPacketmanager:
239 log = logging.getLogger("mpmanager") 240
241 - def __init__(self, mpmanager):
242 self.mpmanager = mpmanager 243 self.command_packet_list = []
244
245 - def is_tick_ready(self, tick):
246 """Check if packets from all players have arrived (necessary for tick to begin)""" 247 ready = len(self.get_packets_for_tick(tick, remove_returned_commands=False)) == self.mpmanager.get_player_count() 248 if not ready: 249 self.log.debug("tick not ready, packets: %s", str(list(str(x) for x in self.get_packets_for_tick(tick, remove_returned_commands=False)))) 250 return ready
251
252 - def get_packets_for_tick(self, tick, remove_returned_commands=True):
253 """Returns packets that are to be executed at a certain tick""" 254 command_packets = [x for x in self.command_packet_list if x.tick == tick] 255 if remove_returned_commands: 256 self.command_packet_list = [x for x in self.command_packet_list if x.tick != tick] 257 return command_packets
258
259 - def get_packets_from_player(self, player_id):
260 """ 261 Returns all command this player has issued, that are not yet executed 262 @param player_id: worldid of player 263 """ 264 return [x for x in self.command_packet_list if x.player_id == player_id]
265
266 - def add_packet(self, command_packet):
267 """Receive a packet""" 268 self.command_packet_list.append(command_packet)
269
270 271 -class MPCommandsManager(MPPacketmanager):
272 pass
273
274 275 -class MPCheckupHashManager(MPPacketmanager):
276 - def is_tick_ready(self, tick):
277 # we only check hash for every HASH_EVAL_DISTANCE tick 278 # if the current tick isn't checked we don't need any packets and are always ready 279 if tick % self.mpmanager.HASH_EVAL_DISTANCE != 0: 280 return True 281 return super().is_tick_ready(tick)
282
283 - def are_checkup_hash_values_equal(self, tick, cb_diff=None):
284 """ 285 @param packages for tick 286 @param cb_diff: called in case hashes differ 287 @return False if they are not equal 288 """ 289 pkges = self.get_packets_for_tick(tick) 290 for pkg in pkges[1:]: 291 if pkges[0].checkup_hash != pkg.checkup_hash: 292 if cb_diff is not None: 293 localplayerid = self.mpmanager.session.world.player.worldid 294 cb_diff("local" if pkges[0].player_id == localplayerid else "pl#{:02d}".format(pkges[0].player_id), 295 pkges[0].checkup_hash, 296 "local" if pkg.player_id == localplayerid else "pl#{:02d}".format(pkg.player_id), 297 pkg.checkup_hash) 298 return False 299 return True
300
301 # Packages transmitted over the network 302 ####################################### 303 304 305 -class MPPacket:
306 """Packet to be sent from every player to every player"""
307 - def __init__(self, tick, player_id):
308 """ 309 @param player_id: worldid of player 310 """ 311 self.tick = tick 312 self.player_id = player_id
313 314 @classmethod
315 - def allow_network(cls, klass):
316 """ 317 NOTE: this is a security related method and may lead to 318 execution of arbritary code if used in a wrong way 319 see documentation inside horizons.network.packets.SafeUnpickler 320 """ 321 packets.SafeUnpickler.add('server', klass)
322
323 - def __str__(self):
324 return "packet " + str(self.__class__) + " from player " + str(WorldObject.get_object_by_id(self.player_id)) + " for tick " + str(self.tick)
325
326 327 -class CommandPacket(MPPacket):
328 """Packet to be sent from every player to every player every tick. 329 Contains list of packets to be executed as well as the designated execution time. 330 Also acts as ping (game will stop if a packet for a certain tick hasn't arrived)"""
331 - def __init__(self, tick, player_id, commandlist):
332 super().__init__(tick, player_id) 333 self.commandlist = commandlist
334 335 336 MPPacket.allow_network(CommandPacket)
337 338 339 -class CheckupHashPacket(MPPacket):
340 - def __init__(self, tick, player_id, checkup_hash):
341 super().__init__(tick, player_id) 342 self.checkup_hash = checkup_hash
343 344 345 MPPacket.allow_network(CheckupHashPacket) 346