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