1import copy 2from collections import Counter, OrderedDict as odict 3from logging import error, info, warning, debug 4from operator import itemgetter 5from time import time 6 7import freeOrionAIInterface as fo # pylint: disable=import-error 8from common.print_utils import Table, Text, Float 9 10import AIFleetMission 11import ColonisationAI 12import ExplorationAI 13import FleetUtilsAI 14from EnumsAI import MissionType, ShipRoleType 15import CombatRatingsAI 16import MilitaryAI 17import PlanetUtilsAI 18from freeorion_tools import get_partial_visibility_turn 19from AIDependencies import INVALID_ID, TECH_NATIVE_SPECIALS 20from character.character_module import create_character, Aggression 21 22# moving ALL or NEARLY ALL 'global' variables into AIState object rather than module 23# in general, leaving items as a module attribute if they are recalculated each turn without reference to prior values 24# global variables 25colonyTargetedSystemIDs = [] 26outpostTargetedSystemIDs = [] 27opponentPlanetIDs = [] 28invasionTargets = [] 29invasionTargetedSystemIDs = [] 30fleetsLostBySystem = {} # keys are system_ids, values are ratings for the fleets lost 31empireStars = {} 32 33 34class ConversionError(Exception): 35 """Exception to be raised if the conversion of a savegame state fails. 36 37 Automatically logs and chats to the host if raised. 38 """ 39 40 def __init__(self, msg=""): 41 error(msg, exc_info=True) 42 43 44def convert_to_version(state, version): 45 """Convert a savegame AIstate to the next version. 46 47 :param dict state: savegame state, modified in function 48 :param int version: Version to convert to 49 """ 50 debug("Trying to convert savegame state to version %d..." % version) 51 current_version = state.get("version", -1) 52 debug(" Current version: %d" % current_version) 53 if current_version == version: 54 raise ConversionError("Can't convert AI savegame to the same compatibility version.") 55 56 if current_version > version: 57 raise ConversionError("Can't convert AI savegame to an older compatibility version.") 58 59 if version != current_version + 1: 60 raise ConversionError("Can't skip a compatibility version when converting AI savegame.") 61 62 # Starting with version 3, we switched from pickle to json-style encoding 63 # Do not try to load an older savegame even if it magically passed the encoder. 64 if version <= 3: 65 raise ConversionError("The AI savegame version is no longer supported.") 66 67 if version == 4: 68 del state['qualifyingOutpostBaseTargets'] 69 del state['qualifyingColonyBaseTargets'] 70 state['orbital_colonization_manager'] = ColonisationAI.OrbitalColonizationManager() 71 72 if version == 5: 73 state['last_turn_played'] = 0 74 75 if version == 6: 76 # Anti-fighter and anti-planet stats were added to CombatRatingAI 77 state['_AIstate__empire_standard_enemy'] = state['_AIstate__empire_standard_enemy'] + (0, False) + (0, False) 78 79 # state["some_new_member"] = some_default_value 80 # del state["some_removed_member"] 81 # state["list_changed_to_set"] = set(state["list_changed_to_set"]) 82 83 debug(" All updates set. Setting new version number.") 84 state["version"] = version 85 86 87class AIstate: 88 """Stores AI game state. 89 90 IMPORTANT: 91 (i) If class members are redefined, added or deleted, then the 92 version number must be increased by 1 and the convert_to_version() 93 function must be updated so a saved state from the previous version 94 is playable with this AIstate version, i.e. new members must be added 95 and outdated members must be modified and / or deleted. 96 97 (ii) The AIstate is stored as an encoded string in save game files 98 (currently via the pickle module). The attributes of the AIstate must 99 therefore be compatible with the encoding method, which currently generally 100 means that they must be native python data types (or other data types the 101 encoder is augmented to handle), not objects such as UniverseObject 102 instances or C++ enum values brought over from the C++ side 103 via boost. If desiring to store a reference to a UniverseObject store its 104 object id instead; for enum values store their int conversion value. 105 """ 106 version = 6 107 108 def __init__(self, aggression): 109 # Do not allow to create AIstate instances with an invalid version number. 110 if not hasattr(AIstate, 'version'): 111 raise ConversionError("AIstate must have an integer version attribute for savegame compatibility") 112 if not isinstance(AIstate.version, int): 113 raise ConversionError("Version attribute of AIstate must be an integer!") 114 if AIstate.version < 0: 115 raise ConversionError("AIstate savegame compatibility version must be a positive integer!") 116 117 # need to store the version explicitly as the class variable "version" is only stored in the 118 # self.__class__.__dict__ while we only pickle the object (i.e. self.__dict__ ) 119 self.version = AIstate.version 120 121 # Debug info 122 # unique id for game 123 self.uid = self.generate_uid(first=True) 124 # unique ids for turns. {turn: uid} 125 self.turn_uids = {} 126 127 # see AIstate docstring re importance of int cast for aggression 128 self._aggression = int(aggression) 129 130 # 'global' (?) variables 131 self.colonisablePlanetIDs = odict() 132 self.colonisableOutpostIDs = odict() # 133 self.__aiMissionsByFleetID = {} 134 self.__shipRoleByDesignID = {} 135 self.__fleetRoleByID = {} 136 self.diplomatic_logs = {} 137 self.__priorityByType = {} 138 139 # initialize home system knowledge 140 universe = fo.getUniverse() 141 empire = fo.getEmpire() 142 self.empireID = empire.empireID 143 homeworld = universe.getPlanet(empire.capitalID) 144 self.__origin_home_system_id = homeworld.systemID if homeworld else INVALID_ID 145 self.visBorderSystemIDs = {self.__origin_home_system_id} 146 self.visInteriorSystemIDs = set() 147 self.exploredSystemIDs = set() 148 self.unexploredSystemIDs = {self.__origin_home_system_id} 149 self.fleetStatus = {} # keys: 'sysID', 'nships', 'rating' 150 # systemStatus keys: 151 # 'name', 'neighbors' (sysIDs), '2jump_ring' (sysIDs), '3jump_ring', '4jump_ring', 'enemy_ship_count', 152 # 'fleetThreat', 'planetThreat', 'monsterThreat' (specifically, immobile nonplanet threat), 'totalThreat', 153 # 'localEnemyFleetIDs', 'neighborThreat', 'max_neighbor_threat', 'jump2_threat' (up to 2 jumps away), 154 # 'jump3_threat', 'jump4_threat', 'regional_threat', 'myDefenses' (planet rating), 'myfleets', 155 # 'myFleetsAccessible'(not just next desitination), 'myFleetRating', 'my_neighbor_rating' (up to 1 jump away), 156 # 'my_jump2_rating', 'my_jump3_rating', my_jump4_rating', 'local_fleet_threats', 157 # 'regional_fleet_threats' <== these are only for mobile fleet threats 158 self.systemStatus = {} 159 self.needsEmergencyExploration = [] 160 self.newlySplitFleets = {} 161 self.militaryRating = 0 162 self.shipCount = 4 163 self.misc = {} # Keys: "enemies_sighted" (dict[turn: list[fleetIDs]]), 164 # "observed_empires" (set[enemy empire IDs]), 165 # "ReassignedFleetMissions" (list[FleetMissions]) 166 self.orbital_colonization_manager = ColonisationAI.OrbitalColonizationManager() 167 self.qualifyingTroopBaseTargets = {} 168 # TODO: track on a per-empire basis 169 self.__empire_standard_enemy = CombatRatingsAI.default_ship_stats().get_stats(hashable=True) 170 self.empire_standard_enemy_rating = 0 # TODO: track on a per-empire basis 171 self.character = create_character(aggression, self.empireID) 172 self.last_turn_played = 0 173 174 def __setstate__(self, state): 175 try: 176 for v in range(state.get("version", -1), AIstate.version): 177 convert_to_version(state, v+1) 178 except ConversionError: 179 if '_aggression' in state: 180 aggression = state['_aggression'] 181 else: 182 try: 183 aggression = state['character'].get_trait(Aggression).key 184 except Exception: 185 error("Could not find the aggression level of the AI, defaulting to typical.", exc_info=True) 186 aggression = fo.aggression.typical 187 self.__init__(aggression) 188 return 189 190 # build the ordered dict with sorted entries from the (unsorted) dict 191 # that is contained in the savegame state. 192 for content in ("colonisablePlanetIDs", "colonisableOutpostIDs"): 193 sorted_planets = sorted(state[content].items(), 194 key=itemgetter(1), reverse=True) 195 state[content] = odict(sorted_planets) 196 197 self.__dict__ = state 198 199 def generate_uid(self, first=False): 200 """ 201 Generates unique identifier. 202 It is hexed number of milliseconds. 203 To set self.uid use flag first=True result will be 204 number of mils between current time and some recent date 205 For turn result is mils between uid and current time 206 """ 207 time_delta = (time() - 1433809768) * 1000 208 if not first: 209 time_delta - int(self.uid, 16) 210 res = hex(int(time_delta))[2:].strip('L') 211 return res 212 213 def set_turn_uid(self): 214 """ 215 Set turn uid. Should be called once per generateOrders. 216 When game loaded same turn can be evaluated once again. We force change id for it. 217 """ 218 uid = self.generate_uid() 219 self.turn_uids[fo.currentTurn()] = uid 220 return uid 221 222 def get_current_turn_uid(self): 223 """ 224 Return uid of current turn. 225 """ 226 return self.turn_uids.setdefault(fo.currentTurn(), self.generate_uid()) 227 228 def get_prev_turn_uid(self): 229 """ 230 Return uid of previous turn. 231 If called during the first turn after loading a saved game that had an AI version not yet using uids 232 will return default value. 233 """ 234 return self.turn_uids.get(fo.currentTurn() - 1, '0') 235 236 def __refresh(self): 237 """Turn start AIstate cleanup/refresh.""" 238 fleetsLostBySystem.clear() 239 invasionTargets[:] = [] 240 241 def __border_exploration_update(self): 242 universe = fo.getUniverse() 243 exploration_center = PlanetUtilsAI.get_capital_sys_id() 244 # a bad state probably from an old savegame, or else empire has lost (or almost has) 245 if exploration_center == INVALID_ID: 246 exploration_center = self.__origin_home_system_id 247 ExplorationAI.graph_flags.clear() 248 if fo.currentTurn() < 50: 249 debug("-------------------------------------------------") 250 debug("Border Exploration Update (relative to %s)" % universe.getSystem(exploration_center)) 251 debug("-------------------------------------------------") 252 if self.visBorderSystemIDs == {INVALID_ID}: 253 self.visBorderSystemIDs.clear() 254 self.visBorderSystemIDs.add(exploration_center) 255 for sys_id in list(self.visBorderSystemIDs): # This set is modified during iteration. 256 if fo.currentTurn() < 50: 257 debug("Considering border system %s" % universe.getSystem(sys_id)) 258 ExplorationAI.follow_vis_system_connections(sys_id, exploration_center) 259 newly_explored = ExplorationAI.update_explored_systems() 260 nametags = [] 261 for sys_id in newly_explored: 262 newsys = universe.getSystem(sys_id) 263 # an explored system *should* always be able to be gotten 264 nametags.append("ID:%4d -- %-20s" % (sys_id, (newsys and newsys.name) or "name unknown")) 265 if newly_explored: 266 debug("-------------------------------------------------") 267 debug("Newly explored systems:\n%s" % "\n".join(nametags)) 268 debug("-------------------------------------------------") 269 270 def delete_fleet_info(self, fleet_id): 271 if fleet_id in self.__aiMissionsByFleetID: 272 del self.__aiMissionsByFleetID[fleet_id] 273 if fleet_id in self.fleetStatus: 274 del self.fleetStatus[fleet_id] 275 if fleet_id in self.__fleetRoleByID: 276 del self.__fleetRoleByID[fleet_id] 277 for sys_status in self.systemStatus.values(): 278 for fleet_list in [sys_status.get('myfleets', []), sys_status.get('myFleetsAccessible', [])]: 279 if fleet_id in fleet_list: 280 fleet_list.remove(fleet_id) 281 282 def __report_system_threats(self): 283 """Print a table with system threats to the logfile.""" 284 current_turn = fo.currentTurn() 285 if current_turn >= 100: 286 return 287 threat_table = Table([ 288 Text('System'), Text('Vis.'), Float('Total'), Float('by Monsters'), Float('by Fleets'), 289 Float('by Planets'), Float('1 jump away'), Float('2 jumps'), Float('3 jumps')], 290 table_name="System Threat Turn %d" % current_turn 291 ) 292 universe = fo.getUniverse() 293 for sys_id in universe.systemIDs: 294 sys_status = self.systemStatus.get(sys_id, {}) 295 system = universe.getSystem(sys_id) 296 threat_table.add_row([ 297 system, 298 "Yes" if sys_status.get('currently_visible', False) else "No", 299 sys_status.get('totalThreat', 0), 300 sys_status.get('monsterThreat', 0), 301 sys_status.get('fleetThreat', 0), 302 sys_status.get('planetThreat', 0), 303 sys_status.get('neighborThreat', 0.0), 304 sys_status.get('jump2_threat', 0.0), 305 sys_status.get('jump3_threat', 0.0), 306 ]) 307 info(threat_table) 308 309 def __report_system_defenses(self): 310 """Print a table with system defenses to the logfile.""" 311 current_turn = fo.currentTurn() 312 if current_turn >= 100: 313 return 314 defense_table = Table([ 315 Text('System Defenses'), Float('Total'), Float('by Planets'), Float('by Fleets'), 316 Float('Fleets 1 jump away'), Float('2 jumps'), Float('3 jumps')], 317 table_name="System Defenses Turn %d" % current_turn 318 ) 319 universe = fo.getUniverse() 320 for sys_id in universe.systemIDs: 321 sys_status = self.systemStatus.get(sys_id, {}) 322 system = universe.getSystem(sys_id) 323 defense_table.add_row([ 324 system, 325 sys_status.get('all_local_defenses', 0.0), 326 sys_status.get('mydefenses', {}).get('overall', 0.0), 327 sys_status.get('myFleetRating', 0.0), 328 sys_status.get('my_neighbor_rating', 0.0), 329 sys_status.get('my_jump2_rating', 0.0), 330 sys_status.get('my_jump3_rating', 0.0), 331 ]) 332 info(defense_table) 333 334 def assess_planet_threat(self, pid, sighting_age=0): 335 if sighting_age > 5: 336 sighting_age += 1 # play it safe 337 universe = fo.getUniverse() 338 planet = universe.getPlanet(pid) 339 if not planet: 340 return {'overall': 0, 'attack': 0, 'health': 0} 341 init_shields = planet.initialMeterValue(fo.meterType.shield) 342 next_shields = planet.currentMeterValue(fo.meterType.shield) # always assumes regen will occur 343 max_shields = planet.currentMeterValue(fo.meterType.maxShield) 344 init_defense = planet.initialMeterValue(fo.meterType.defense) 345 next_defense = planet.currentMeterValue(fo.meterType.defense) # always assumes regen will occur 346 max_defense = planet.currentMeterValue(fo.meterType.maxDefense) 347 for special, bonuses in TECH_NATIVE_SPECIALS.items(): 348 if special in planet.specials and sighting_age > 0: 349 shield_bonus = bonuses.get('shields', 0) 350 defense_bonus = bonuses.get('defense', 0) 351 max_shields = max(max_shields, shield_bonus) 352 max_defense = max(max_defense, defense_bonus) 353 next_shields, init_shields = max(next_shields, shield_bonus), max(init_shields, shield_bonus) 354 next_defense, init_defense = max(next_defense, defense_bonus), max(init_defense, defense_bonus) 355 # TODO: get regens from knowledge of possessed tech 356 # note the max below is because sometimes the next value will be less than init 357 # (e.g. shields just after invasion) 358 shield_regen = max(1, next_shields - init_shields) 359 defense_regen = max(1, next_defense - init_defense) 360 shields = min(max_shields, init_shields + sighting_age * shield_regen) 361 defense = min(max_defense, init_defense + sighting_age * defense_regen) 362 return {'overall': defense * (defense + shields), 'attack': defense, 'health': (defense + shields)} 363 364 def assess_enemy_supply(self): 365 """ 366 Assesses where enemy empires have Supply 367 :return: a tuple of 2 dicts, each of which is keyed by system id, and each of which is a list of empire ids 368 1st dict -- enemies that actually have supply at this system 369 2nd dict -- enemies that have supply within 2 jumps from this system (if they clear obstructions) 370 :rtype: (dict[int, list[int]], dict[int, list[int]]) 371 """ 372 enemy_ids = [_id for _id in fo.allEmpireIDs() if _id != fo.empireID()] 373 actual_supply = {} 374 near_supply = {} 375 for enemy_id in enemy_ids: 376 this_enemy = fo.getEmpire(enemy_id) 377 if not this_enemy: 378 debug("Could not retrieve empire for empire id %d" % enemy_id) # do not spam chat_error with this 379 continue 380 for sys_id in this_enemy.fleetSupplyableSystemIDs: 381 actual_supply.setdefault(sys_id, []).append(enemy_id) 382 for sys_id, supply_val in this_enemy.supplyProjections().items(): 383 if supply_val >= -2: 384 near_supply.setdefault(sys_id, []).append(enemy_id) 385 return actual_supply, near_supply 386 387 def __update_empire_standard_enemy(self): 388 """Update the empire's standard enemy. 389 390 The standard enemy is the enemy that is most often seen. 391 """ 392 # TODO: If no current information available, rate against own fighters 393 universe = fo.getUniverse() 394 empire_id = fo.empireID() 395 396 # assess enemy fleets that may have been momentarily visible (start with dummy entries) 397 dummy_stats = CombatRatingsAI.default_ship_stats().get_stats(hashable=True) 398 cur_e_fighters = Counter() # actual visible enemies 399 old_e_fighters = Counter({dummy_stats: 0}) # destroyed enemies TODO: consider seen but out of sight enemies 400 401 for fleet_id in universe.fleetIDs: 402 fleet = universe.getFleet(fleet_id) 403 if (not fleet or fleet.empty or fleet.ownedBy(empire_id) or fleet.unowned or 404 not (fleet.hasArmedShips or fleet.hasFighterShips)): 405 continue 406 407 # track old/dead enemy fighters for rating assessments in case not enough current info 408 ship_stats = CombatRatingsAI.FleetCombatStats(fleet_id).get_ship_stats(hashable=True) 409 dead_fleet = fleet_id in universe.destroyedObjectIDs(empire_id) 410 e_f_dict = old_e_fighters if dead_fleet else cur_e_fighters 411 for stats in ship_stats: 412 # log only ships that are armed 413 if stats[0]: 414 e_f_dict[stats] += 1 415 416 e_f_dict = cur_e_fighters or old_e_fighters 417 self.__empire_standard_enemy = sorted([(v, k) for k, v in e_f_dict.items()])[-1][1] 418 self.empire_standard_enemy_rating = self.get_standard_enemy().get_rating() 419 420 def __update_system_status(self): 421 debug('{0} Updating System Threats {0}'.format(10 * "=")) 422 universe = fo.getUniverse() 423 empire = fo.getEmpire() 424 empire_id = fo.empireID() 425 destroyed_object_ids = universe.destroyedObjectIDs(empire_id) 426 supply_unobstructed_systems = set(empire.supplyUnobstructedSystems) 427 min_hidden_attack = 4 428 min_hidden_health = 8 429 observed_empires = self.misc.setdefault("observed_empires", set()) 430 431 # TODO: Variables that are recalculated each turn from scratch should not be stored in AIstate 432 # clear previous game state 433 for sys_id in self.systemStatus: 434 self.systemStatus[sys_id]['enemy_ship_count'] = 0 435 self.systemStatus[sys_id]['myFleetRating'] = 0 436 self.systemStatus[sys_id]['myFleetRatingVsPlanets'] = 0 437 438 # for use in debugging 439 verbose = False 440 441 # assess enemy fleets that may have been momentarily visible 442 enemies_by_system = {} 443 my_fleets_by_system = {} 444 fleet_spot_position = {} 445 current_turn = fo.currentTurn() 446 for fleet_id in universe.fleetIDs: 447 fleet = universe.getFleet(fleet_id) 448 if not fleet or fleet.empty: 449 self.delete_fleet_info(fleet_id) # this is safe even if fleet wasn't mine 450 continue 451 # TODO: check if currently in system and blockaded before accepting destination as location 452 this_system_id = fleet.nextSystemID if fleet.nextSystemID != INVALID_ID else fleet.systemID 453 dead_fleet = fleet_id in destroyed_object_ids 454 if dead_fleet: 455 self.delete_fleet_info(fleet_id) 456 457 if fleet.ownedBy(empire_id): 458 if not dead_fleet: 459 my_fleets_by_system.setdefault(this_system_id, []).append(fleet_id) 460 fleet_spot_position.setdefault(fleet.systemID, []).append(fleet_id) 461 continue 462 463 # TODO: consider checking death of individual ships. If ships had been moved from this fleet 464 # into another fleet, we might have witnessed their death in that other fleet but if this fleet 465 # had not been seen since before that transfer then the ships might also still be listed here. 466 if dead_fleet: 467 continue 468 469 # we are only interested in immediately recent data 470 if get_partial_visibility_turn(fleet_id) < (current_turn - 1): 471 continue 472 473 sys_status = self.systemStatus.setdefault(this_system_id, {}) 474 sys_status['enemy_ship_count'] = sys_status.get('enemy_ship_count', 0) + len(fleet.shipIDs) 475 enemies_by_system.setdefault(this_system_id, []).append(fleet_id) 476 477 if not fleet.unowned: 478 self.misc.setdefault('enemies_sighted', {}).setdefault(current_turn, []).append(fleet_id) 479 observed_empires.add(fleet.owner) 480 481 # assess fleet and planet threats & my local fleets 482 for sys_id in universe.systemIDs: 483 sys_status = self.systemStatus.setdefault(sys_id, {}) 484 system = universe.getSystem(sys_id) 485 if verbose: 486 debug("AIState threat evaluation for %s" % system) 487 # update fleets 488 sys_status['myfleets'] = my_fleets_by_system.get(sys_id, []) 489 sys_status['myFleetsAccessible'] = fleet_spot_position.get(sys_id, []) 490 local_enemy_fleet_ids = enemies_by_system.get(sys_id, []) 491 sys_status['localEnemyFleetIDs'] = local_enemy_fleet_ids 492 if system: 493 sys_status['name'] = system.name 494 495 # update my fleet rating versus planets so that planet ratings can be more accurate 496 my_ratings_against_planets_list = [] 497 for fid in sys_status['myfleets']: 498 my_ratings_against_planets_list.append(self.get_rating(fid, against_planets=True)) 499 sys_status['myFleetRatingVsPlanets'] = CombatRatingsAI.combine_ratings_list( 500 my_ratings_against_planets_list) 501 502 # update threats 503 monster_ratings = [] # immobile 504 enemy_ratings = [] # owned & mobile 505 mob_ratings = [] # mobile & unowned 506 mobile_fleets = [] # mobile and either owned or unowned 507 for fid in local_enemy_fleet_ids: 508 fleet = universe.getFleet(fid) # ensured to exist 509 fleet_rating = CombatRatingsAI.get_fleet_rating( 510 fid, enemy_stats=CombatRatingsAI.get_empire_standard_fighter()) 511 if fleet.speed == 0: 512 monster_ratings.append(fleet_rating) 513 if verbose: 514 debug("\t immobile enemy fleet %s has rating %.1f" % (fleet, fleet_rating)) 515 continue 516 517 if verbose: 518 debug("\t mobile enemy fleet %s has rating %.1f" % (fleet, fleet_rating)) 519 mobile_fleets.append(fid) 520 if fleet.unowned: 521 mob_ratings.append(fleet_rating) 522 else: 523 enemy_ratings.append(fleet_rating) 524 525 enemy_rating = CombatRatingsAI.combine_ratings_list(enemy_ratings) 526 monster_rating = CombatRatingsAI.combine_ratings_list(monster_ratings) 527 mob_rating = CombatRatingsAI.combine_ratings_list(mob_ratings) 528 lost_fleets = fleetsLostBySystem.get(sys_id, []) 529 lost_fleet_rating = CombatRatingsAI.combine_ratings_list(lost_fleets) 530 if lost_fleet_rating: 531 debug("Just lost fleet rating %.1f in system %s", lost_fleet_rating, system) 532 533 # under current visibility rules should not be possible to have any losses or other info here, 534 # but just in case... 535 partial_vis_turn = get_partial_visibility_turn(sys_id) 536 if not system or partial_vis_turn < 0: 537 if verbose: 538 debug("Never had partial vis for %s - basing threat assessment on old info and lost ships" % system) 539 sys_status.setdefault('local_fleet_threats', set()) 540 sys_status['planetThreat'] = 0 541 sys_status['fleetThreat'] = max( 542 CombatRatingsAI.combine_ratings(enemy_rating, mob_rating), 543 0.98 * sys_status.get('fleetThreat', 0), 544 1.1*lost_fleet_rating - monster_rating) 545 sys_status['monsterThreat'] = max( 546 monster_rating, 547 0.98 * sys_status.get('monsterThreat', 0), 548 1.1*lost_fleet_rating - enemy_rating - mob_rating) 549 sys_status['enemy_threat'] = max( 550 enemy_rating, 551 0.98 * sys_status.get('enemy_threat', 0), 552 1.1*lost_fleet_rating - monster_rating - mob_rating) 553 sys_status['mydefenses'] = {'overall': 0, 'attack': 0, 'health': 0} 554 sys_status['totalThreat'] = sys_status['fleetThreat'] 555 sys_status['regional_fleet_threats'] = sys_status['local_fleet_threats'].copy() 556 continue 557 558 # have either stale or current info 559 pattack = phealth = 0 560 mypattack = myphealth = 0 561 for pid in system.planetIDs: 562 planet = universe.getPlanet(pid) 563 if not planet: 564 continue 565 sighting_age = current_turn - get_partial_visibility_turn(pid) 566 prating = self.assess_planet_threat(pid, sighting_age) 567 if planet.ownedBy(empire_id): # TODO: check for diplomatic status 568 mypattack += prating['attack'] 569 myphealth += prating['health'] 570 else: 571 pattack += prating['attack'] 572 phealth += prating['health'] 573 if any("_NEST_" in special for special in planet.specials): 574 sys_status['nest_threat'] = 100 575 sys_status['planetThreat'] = pattack * phealth 576 sys_status['mydefenses'] = {'overall': mypattack * myphealth, 'attack': mypattack, 'health': myphealth} 577 578 # previous threat assessment could account for losses, ignore the losses now 579 if (lost_fleet_rating and 580 lost_fleet_rating < max(sys_status.get('totalThreat', 0), pattack * phealth)): 581 debug("In system %s: Ignoring lost fleets since known threats could cause it.", system) 582 lost_fleet_rating = 0 583 584 # TODO use sitrep combat info rather than estimating stealthed enemies by fleets lost to them 585 # TODO also only consider past stealthed fleet threat to still be present if the system is still obstructed 586 # TODO: track visibility across turns in order to distinguish the blip of visibility in (losing) combat, 587 # which FO currently treats as being for the previous turn, 588 # partially superseding the previous visibility for that turn 589 590 if not partial_vis_turn == current_turn: 591 sys_status.setdefault('local_fleet_threats', set()) 592 sys_status['currently_visible'] = False 593 # print ("Stale visibility for system %d ( %s ) -- last seen %d, " 594 # "current Turn %d -- basing threat assessment on old info and lost ships") % ( 595 # sys_id, sys_status.get('name', "name unknown"), partial_vis_turn, currentTurn) 596 sys_status['fleetThreat'] = max( 597 CombatRatingsAI.combine_ratings(enemy_rating, mob_rating), 598 0.98 * sys_status.get('fleetThreat', 0), 599 2.0 * lost_fleet_rating - max(sys_status.get('monsterThreat', 0), monster_rating)) 600 sys_status['enemy_threat'] = max( 601 enemy_rating, 602 0.98 * sys_status.get('enemy_threat', 0), 603 1.1*lost_fleet_rating - max(sys_status.get('monsterThreat', 0), monster_rating)) 604 sys_status['monsterThreat'] = max(monster_rating, 0.98 * sys_status.get('monsterThreat', 0)) 605 # sys_status['totalThreat'] = ((pattack + enemy_attack + monster_attack) ** 0.8)\ 606 # * ((phealth + enemy_health + monster_health)** 0.6) # reevaluate this 607 sys_status['totalThreat'] = max( 608 CombatRatingsAI.combine_ratings_list([enemy_rating, mob_rating, monster_rating, pattack * phealth]), 609 2 * lost_fleet_rating, 610 0.98 * sys_status.get('totalThreat', 0)) 611 else: # system considered visible 612 sys_status['currently_visible'] = True 613 sys_status['local_fleet_threats'] = set(mobile_fleets) 614 # includes mobile monsters 615 sys_status['fleetThreat'] = max( 616 CombatRatingsAI.combine_ratings(enemy_rating, mob_rating), 2*lost_fleet_rating - monster_rating) 617 if verbose: 618 debug("enemy threat calc parts: enemy rating %.1f, lost fleet rating %.1f, monster_rating %.1f" % ( 619 enemy_rating, lost_fleet_rating, monster_rating)) 620 # does NOT include mobile monsters 621 sys_status['enemy_threat'] = max(enemy_rating, 2*lost_fleet_rating - monster_rating) 622 sys_status['monsterThreat'] = monster_rating 623 sys_status['totalThreat'] = CombatRatingsAI.combine_ratings_list([ 624 sys_status['fleetThreat'], 625 sys_status['monsterThreat'], 626 pattack * phealth, 627 ]) 628 sys_status['regional_fleet_threats'] = sys_status['local_fleet_threats'].copy() 629 sys_status['fleetThreat'] = max(sys_status['fleetThreat'], sys_status.get('nest_threat', 0)) 630 sys_status['totalThreat'] = max(sys_status['totalThreat'], sys_status.get('nest_threat', 0)) 631 632 # has been seen with Partial Vis, but is currently supply-blocked 633 if partial_vis_turn > 0 and sys_id not in supply_unobstructed_systems: 634 sys_status['fleetThreat'] = max(sys_status['fleetThreat'], min_hidden_attack * min_hidden_health) 635 sys_status['totalThreat'] = max(sys_status['totalThreat'], 636 CombatRatingsAI.combine_ratings(sys_status.get('planetThreat', 0), 637 (min_hidden_attack*min_hidden_health))) 638 if verbose and sys_status['fleetThreat'] > 0: 639 debug("%s intermediate status: %s" % (system, sys_status)) 640 641 enemy_supply, enemy_near_supply = self.assess_enemy_supply() # TODO: assess change in enemy supply over time 642 # assess secondary threats (threats of surrounding systems) and update my fleet rating 643 for sys_id in universe.systemIDs: 644 sys_status = self.systemStatus[sys_id] 645 sys_status['enemies_supplied'] = enemy_supply.get(sys_id, []) 646 observed_empires.update(enemy_supply.get(sys_id, [])) 647 sys_status['enemies_nearly_supplied'] = enemy_near_supply.get(sys_id, []) 648 my_ratings_list = [] 649 my_ratings_against_planets_list = [] 650 for fid in sys_status['myfleets']: 651 this_rating = self.get_rating(fid, True, self.get_standard_enemy()) 652 my_ratings_list.append(this_rating) 653 my_ratings_against_planets_list.append(self.get_rating(fid, against_planets=True)) 654 if sys_id != INVALID_ID: 655 sys_status['myFleetRating'] = CombatRatingsAI.combine_ratings_list(my_ratings_list) 656 sys_status['myFleetRatingVsPlanets'] = CombatRatingsAI.combine_ratings_list( 657 my_ratings_against_planets_list) 658 sys_status['all_local_defenses'] = CombatRatingsAI.combine_ratings( 659 sys_status['myFleetRating'], sys_status['mydefenses']['overall']) 660 sys_status['neighbors'] = set(universe.getImmediateNeighbors(sys_id, self.empireID)) 661 662 for sys_id in universe.systemIDs: 663 sys_status = self.systemStatus[sys_id] 664 neighbors = sys_status.get('neighbors', set()) 665 this_system = universe.getSystem(sys_id) 666 if verbose: 667 debug("Regional Assessment for %s with local fleet threat %.1f" % ( 668 this_system, sys_status.get('fleetThreat', 0))) 669 jumps2 = set() 670 jumps3 = set() 671 jumps4 = set() 672 for seta, setb in [(neighbors, jumps2), (jumps2, jumps3), (jumps3, jumps4)]: 673 for sys2id in seta: 674 setb.update(self.systemStatus.get(sys2id, {}).get('neighbors', set())) 675 jump2ring = jumps2 - neighbors - {sys_id} 676 jump3ring = jumps3 - jumps2 - neighbors - {sys_id} 677 jump4ring = jumps4 - jumps3 - jumps2 - neighbors - {sys_id} 678 sys_status['2jump_ring'] = jump2ring 679 sys_status['3jump_ring'] = jump3ring 680 sys_status['4jump_ring'] = jump4ring 681 threat, max_threat, myrating, j1_threats = self.area_ratings(neighbors) 682 sys_status['neighborThreat'] = threat 683 sys_status['max_neighbor_threat'] = max_threat 684 sys_status['my_neighbor_rating'] = myrating 685 threat, max_threat, myrating, j2_threats = self.area_ratings(jump2ring) 686 sys_status['jump2_threat'] = threat 687 sys_status['my_jump2_rating'] = myrating 688 threat, max_threat, myrating, j3_threats = self.area_ratings(jump3ring) 689 sys_status['jump3_threat'] = threat 690 sys_status['my_jump3_rating'] = myrating 691 # for local system includes both enemies and mobs 692 threat_keys = ['fleetThreat', 'neighborThreat', 'jump2_threat'] 693 sys_status['regional_threat'] = CombatRatingsAI.combine_ratings_list( 694 [sys_status.get(x, 0) for x in threat_keys]) 695 # TODO: investigate cases where regional_threat has been nonzero but no regional_threat_fleets 696 # (probably due to attenuating history of past threats) 697 sys_status.setdefault('regional_fleet_threats', set()).update(j1_threats, j2_threats) 698 699 def area_ratings(self, system_ids): 700 """Returns (fleet_threat, max_threat, myFleetRating, threat_fleets) compiled over a group of systems.""" 701 myrating = threat = max_threat = 0 702 threat_fleets = set() 703 for sys_id in system_ids: 704 sys_status = self.systemStatus.get(sys_id, {}) 705 # TODO: have distinct treatment for both enemy_threat and fleetThreat, respectively 706 fthreat = sys_status.get('enemy_threat', 0) 707 max_threat = max(max_threat, fthreat) 708 threat = CombatRatingsAI.combine_ratings(threat, fthreat) 709 myrating = CombatRatingsAI.combine_ratings(myrating, sys_status.get('myFleetRating', 0)) 710 # myrating = FleetUtilsAI.combine_ratings(myrating, sys_status.get('all_local_defenses', 0)) 711 threat_fleets.update(sys_status.get('local_fleet_threats', [])) 712 return threat, max_threat, myrating, threat_fleets 713 714 def get_fleet_mission(self, fleet_id): 715 """ 716 Returns AIFleetMission with fleetID. 717 :rtype: AIFleetMission.AIFleetMission 718 """ 719 if fleet_id in self.__aiMissionsByFleetID: 720 return self.__aiMissionsByFleetID[fleet_id] 721 else: 722 return None 723 724 def get_all_fleet_missions(self): 725 """Returns all AIFleetMissions.""" 726 return self.__aiMissionsByFleetID.values() 727 728 def get_fleet_missions_map(self): 729 return self.__aiMissionsByFleetID 730 731 def get_fleet_missions_with_any_mission_types(self, mission_types): 732 """Returns all AIFleetMissions which contains any of fleetMissionTypes.""" 733 result = [] 734 for mission in self.get_all_fleet_missions(): 735 if mission.type in mission_types: 736 result.append(mission) 737 return result 738 739 def __add_fleet_mission(self, fleet_id): 740 """Add a new dummy AIFleetMission for the passed fleet_id if it has no mission yet.""" 741 if self.get_fleet_mission(fleet_id) is not None: 742 warning("Tried to add a new fleet mission for fleet that already had a mission.") 743 return 744 self.__aiMissionsByFleetID[fleet_id] = AIFleetMission.AIFleetMission(fleet_id) 745 746 def __remove_fleet_mission(self, fleet_id): 747 """Remove invalid AIFleetMission with fleetID if it exists.""" 748 if self.get_fleet_mission(fleet_id) is not None: 749 self.__aiMissionsByFleetID[fleet_id] = None 750 del self.__aiMissionsByFleetID[fleet_id] 751 752 def ensure_have_fleet_missions(self, fleet_ids): 753 for fleet_id in fleet_ids: 754 if self.get_fleet_mission(fleet_id) is None: 755 self.__add_fleet_mission(fleet_id) 756 757 def __clean_fleet_missions(self): 758 """Assign a new dummy mission to new fleets and clean up existing, now invalid missions.""" 759 current_empire_fleets = FleetUtilsAI.get_empire_fleet_ids() 760 761 # assign a new (dummy) mission to new fleets 762 for fleet_id in current_empire_fleets: 763 if self.get_fleet_mission(fleet_id) is None: 764 self.__add_fleet_mission(fleet_id) 765 766 # Check all fleet missions for validity and clear invalid targets. 767 # If a fleet does not exist anymore, mark mission for deletion. 768 # Deleting only after the loop allows us to avoid an expensive copy. 769 deleted_fleet_ids = [] 770 for mission in self.get_all_fleet_missions(): 771 if mission.fleet.id not in current_empire_fleets: 772 deleted_fleet_ids.append(mission.fleet.id) 773 else: 774 mission.clean_invalid_targets() 775 for deleted_fleet_id in deleted_fleet_ids: 776 self.__remove_fleet_mission(deleted_fleet_id) 777 778 def has_target(self, mission_type, target): 779 for mission in self.get_fleet_missions_with_any_mission_types([mission_type]): 780 if mission.has_target(mission_type, target): 781 return True 782 return False 783 784 def get_rating(self, fleet_id, force_new=False, enemy_stats=None, against_planets=False): 785 """Returns a dict with various rating info.""" 786 if fleet_id in self.fleetStatus and not force_new and enemy_stats is None: 787 return self.fleetStatus[fleet_id].get('rating', 0) 788 else: 789 fleet = fo.getUniverse().getFleet(fleet_id) 790 if not fleet: 791 return {} # TODO: also ensure any info for that fleet is deleted 792 status = {'rating': CombatRatingsAI.get_fleet_rating(fleet_id, enemy_stats), 793 'ratingVsPlanets': CombatRatingsAI.get_fleet_rating_against_planets(fleet_id), 794 'sysID': fleet.systemID, 'nships': len(fleet.shipIDs)} 795 self.fleetStatus[fleet_id] = status 796 return status['rating'] if not against_planets else status['ratingVsPlanets'] 797 798 def update_fleet_rating(self, fleet_id): 799 self.get_rating(fleet_id, force_new=True) 800 801 def get_ship_role(self, ship_design_id): 802 """Returns ship role for given designID, assesses and adds as needed.""" 803 804 # if thought was invalid, recheck to be sure 805 if (ship_design_id in self.__shipRoleByDesignID and 806 self.__shipRoleByDesignID[ship_design_id] != ShipRoleType.INVALID): 807 return self.__shipRoleByDesignID[ship_design_id] 808 else: 809 role = FleetUtilsAI.assess_ship_design_role(fo.getShipDesign(ship_design_id)) 810 self.__shipRoleByDesignID[ship_design_id] = role 811 return role 812 813 def get_fleet_roles_map(self): 814 return self.__fleetRoleByID 815 816 def get_fleet_role(self, fleet_id, force_new=False): 817 """Returns fleet role by ID.""" 818 819 if not force_new and fleet_id in self.__fleetRoleByID: 820 return self.__fleetRoleByID[fleet_id] 821 else: 822 role = FleetUtilsAI.assess_fleet_role(fleet_id) 823 self.__fleetRoleByID[fleet_id] = role 824 make_aggressive = False 825 if role in [MissionType.COLONISATION, 826 MissionType.OUTPOST, 827 MissionType.ORBITAL_INVASION, 828 MissionType.ORBITAL_OUTPOST 829 ]: 830 pass 831 elif role in [MissionType.EXPLORATION, 832 MissionType.INVASION 833 ]: 834 this_rating = self.get_rating(fleet_id) # Done! 835 n_ships = self.fleetStatus.get(fleet_id, {}).get('nships', 1) # entry sould exist due to above line 836 if float(this_rating) / n_ships >= 0.5 * MilitaryAI.cur_best_mil_ship_rating(): 837 make_aggressive = True 838 else: 839 make_aggressive = True 840 fo.issueAggressionOrder(fleet_id, make_aggressive) 841 return role 842 843 def session_start_cleanup(self): 844 self.newlySplitFleets = {} 845 for fleetID in FleetUtilsAI.get_empire_fleet_ids(): 846 self.get_fleet_role(fleetID) 847 self.update_fleet_rating(fleetID) 848 self.ensure_have_fleet_missions([fleetID]) 849 self.__clean_fleet_roles(just_resumed=True) 850 fleetsLostBySystem.clear() 851 empireStars.clear() 852 self.qualifyingTroopBaseTargets.clear() 853 854 def __clean_fleet_roles(self, just_resumed=False): 855 """Removes fleetRoles if a fleet has been lost, and update fleet Ratings.""" 856 universe = fo.getUniverse() 857 current_empire_fleets = FleetUtilsAI.get_empire_fleet_ids() 858 self.shipCount = 0 859 860 fleet_table = Table([ 861 Text('Fleet'), Float('Rating'), Float('Troops'), 862 Text('Location'), Text('Destination')], 863 table_name="Fleet Summary Turn %d" % fo.currentTurn() 864 ) 865 # need to loop over a copy as entries are deleted in loop 866 for fleet_id in list(self.__fleetRoleByID): 867 fleet_status = self.fleetStatus.setdefault(fleet_id, {}) 868 rating = CombatRatingsAI.get_fleet_rating(fleet_id) 869 old_sys_id = fleet_status.get('sysID', -2) # TODO: Introduce helper function instead 870 fleet = universe.getFleet(fleet_id) 871 if fleet: 872 sys_id = fleet.systemID 873 if old_sys_id in [-2, -1]: 874 old_sys_id = sys_id 875 fleet_status['nships'] = len(fleet.shipIDs) # TODO: Introduce helper function instead 876 self.shipCount += fleet_status['nships'] 877 else: 878 # can still retrieve a fleet object even if fleet was just destroyed, so shouldn't get here 879 # however,this has been observed happening, and is the reason a fleet check was added a few lines below. 880 # Not at all sure how this came about, but was throwing off threat assessments 881 sys_id = old_sys_id 882 883 # check if fleet is destroyed and if so, delete stored information 884 if fleet_id not in current_empire_fleets: # or fleet.empty: 885 debug("Just lost %s", fleet) 886 if not just_resumed: 887 fleetsLostBySystem.setdefault(old_sys_id, []).append( 888 max(rating, fleet_status.get('rating', 0.), MilitaryAI.MinThreat)) 889 890 self.delete_fleet_info(fleet_id) 891 continue 892 893 # if reached here, the fleet does still exist 894 this_sys = universe.getSystem(sys_id) 895 next_sys = universe.getSystem(fleet.nextSystemID) 896 897 fleet_table.add_row([ 898 fleet, 899 rating, 900 FleetUtilsAI.count_troops_in_fleet(fleet_id), 901 this_sys or 'starlane', 902 next_sys or '-', 903 ]) 904 905 fleet_status['rating'] = rating 906 if next_sys: 907 fleet_status['sysID'] = next_sys.id 908 elif this_sys: 909 fleet_status['sysID'] = this_sys.id 910 else: 911 error("Fleet %s has no valid system." % fleet) 912 info(fleet_table) 913 # Next string used in charts. Don't modify it! 914 debug("Empire Ship Count: %s" % self.shipCount) 915 debug("Empire standard fighter summary: %s", (CombatRatingsAI.get_empire_standard_fighter().get_stats(), )) 916 debug("------------------------") 917 918 def get_explored_system_ids(self): 919 return list(self.exploredSystemIDs) 920 921 def get_unexplored_system_ids(self): 922 return list(self.unexploredSystemIDs) 923 924 def set_priority(self, priority_type, value): 925 """Sets a priority of the specified type.""" 926 self.__priorityByType[priority_type] = value 927 928 def get_priority(self, priority_type): 929 """Returns the priority value of the specified type.""" 930 931 if priority_type in self.__priorityByType: 932 return copy.deepcopy(self.__priorityByType[priority_type]) 933 return 0 934 935 def __report_last_turn_fleet_missions(self): 936 """Print a table reviewing last turn fleet missions to the log file.""" 937 universe = fo.getUniverse() 938 mission_table = Table( 939 [Text('Fleet'), Text('Mission'), Text('Ships'), Float('Rating'), Float('Troops'), Text('Target')], 940 table_name="Turn %d: Fleet Mission Review from Last Turn" % fo.currentTurn()) 941 for fleet_id, mission in self.get_fleet_missions_map().items(): 942 fleet = universe.getFleet(fleet_id) 943 if not fleet: 944 continue 945 if not mission: 946 mission_table.add_row([fleet]) 947 else: 948 mission_table.add_row([ 949 fleet, 950 mission.type or "None", 951 len(fleet.shipIDs), 952 CombatRatingsAI.get_fleet_rating(fleet_id), 953 FleetUtilsAI.count_troops_in_fleet(fleet_id), 954 mission.target or "-" 955 ]) 956 info(mission_table) 957 958 def __split_new_fleets(self): 959 """Split any new fleets. 960 961 This function is supposed to be called once at the beginning of the turn. 962 Splitting the auto generated fleets at game start or those created by 963 recently built ships allows the AI to assign correct roles to all ships. 964 """ 965 # TODO: check length of fleets for losses or do in AIstate.__cleanRoles 966 universe = fo.getUniverse() 967 known_fleets = self.get_fleet_roles_map() 968 self.newlySplitFleets.clear() 969 970 fleets_to_split = [fleet_id for fleet_id in FleetUtilsAI.get_empire_fleet_ids() if fleet_id not in known_fleets] 971 if fleets_to_split: 972 debug("Trying to split %d new fleets" % len(fleets_to_split)) 973 for fleet_id in fleets_to_split: 974 fleet = universe.getFleet(fleet_id) 975 if not fleet: 976 warning("Trying to split fleet %d but seemingly does not exist" % fleet_id) 977 continue 978 fleet_len = len(fleet.shipIDs) 979 if fleet_len == 1: 980 continue 981 new_fleets = FleetUtilsAI.split_fleet(fleet_id) 982 debug("Split fleet %d with %d ships into %d new fleets:" % (fleet_id, fleet_len, len(new_fleets))) 983 # old fleet may have different role after split, later will be again identified 984 # in current system, orig new fleet will not yet have been assigned a role 985 # self.remove_fleet_role(fleet_id) 986 987 def __cleanup_qualifiying_base_targets(self): 988 """Cleanup invalid entries in qualifying base targets.""" 989 universe = fo.getUniverse() 990 empire_id = fo.empireID() 991 for dct in [self.qualifyingTroopBaseTargets]: 992 for pid in list(dct.keys()): 993 planet = universe.getPlanet(pid) 994 if planet and planet.ownedBy(empire_id): 995 del dct[pid] 996 997 def prepare_for_new_turn(self): 998 self.__report_last_turn_fleet_missions() 999 self.__split_new_fleets() 1000 self.__refresh() # TODO: Use turn_state instead 1001 self.__border_exploration_update() 1002 self.__cleanup_qualifiying_base_targets() 1003 self.orbital_colonization_manager.turn_start_cleanup() 1004 self.__clean_fleet_roles() 1005 self.__clean_fleet_missions() 1006 debug("Fleets lost by system: %s" % fleetsLostBySystem) 1007 self.__update_empire_standard_enemy() 1008 self.__update_system_status() 1009 self.__report_system_threats() 1010 self.__report_system_defenses() 1011 self.__report_exploration_status() 1012 1013 def __report_exploration_status(self): 1014 universe = fo.getUniverse() 1015 explored_system_ids = self.get_explored_system_ids() 1016 debug("Unexplored Systems: %s " % [universe.getSystem(sys_id) for sys_id in self.get_unexplored_system_ids()]) 1017 debug("Explored SystemIDs: %s" % [universe.getSystem(sys_id) for sys_id in explored_system_ids]) 1018 debug("Explored PlanetIDs: %s" % PlanetUtilsAI.get_planets_in__systems_ids(explored_system_ids)) 1019 1020 def log_alliance_request(self, initiating_empire_id, recipient_empire_id): 1021 """Keep a record of alliance requests made or received by this empire.""" 1022 1023 alliance_requests = self.diplomatic_logs.setdefault('alliance_requests', {}) 1024 log_index = (initiating_empire_id, recipient_empire_id) 1025 alliance_requests.setdefault(log_index, []).append(fo.currentTurn()) 1026 1027 def log_peace_request(self, initiating_empire_id, recipient_empire_id): 1028 """Keep a record of peace requests made or received by this empire.""" 1029 1030 peace_requests = self.diplomatic_logs.setdefault('peace_requests', {}) 1031 log_index = (initiating_empire_id, recipient_empire_id) 1032 peace_requests.setdefault(log_index, []).append(fo.currentTurn()) 1033 1034 def log_war_declaration(self, initiating_empire_id, recipient_empire_id): 1035 """Keep a record of war declarations made or received by this empire.""" 1036 1037 # if war declaration is made on turn 1, don't hold it against them 1038 if fo.currentTurn() == 1: 1039 return 1040 war_declarations = self.diplomatic_logs.setdefault('war_declarations', {}) 1041 log_index = (initiating_empire_id, recipient_empire_id) 1042 war_declarations.setdefault(log_index, []).append(fo.currentTurn()) 1043 1044 def get_standard_enemy(self): 1045 return CombatRatingsAI.ShipCombatStats(stats=self.__empire_standard_enemy) 1046