1from logging import debug
2
3import freeOrionAIInterface as fo  # pylint: disable=import-error
4import AIstate
5import CombatRatingsAI
6import EspionageAI
7import FleetUtilsAI
8import InvasionAI
9import PlanetUtilsAI
10import PriorityAI
11import ProductionAI
12from AIDependencies import INVALID_ID
13from aistate_interface import get_aistate
14from CombatRatingsAI import combine_ratings, combine_ratings_list, rating_difference
15from EnumsAI import MissionType
16from freeorion_tools import cache_by_turn_persistent
17from target import TargetSystem
18from turn_state import state
19
20MinThreat = 10  # the minimum threat level that will be ascribed to an unknown threat capable of killing scouts
21_military_allocations = []
22_verbose_mil_reporting = False
23_best_ship_rating_cache = {}  # indexed by turn, value is rating of that turn
24
25
26def cur_best_mil_ship_rating(include_designs=False):
27    """Find the best military ship we have available in this turn and return its rating.
28
29    :param include_designs: toggles if available designs are considered or only existing ships
30    :return: float: rating of the best ship
31    """
32    current_turn = fo.currentTurn()
33    if current_turn in _best_ship_rating_cache:
34        best_rating = _best_ship_rating_cache[current_turn]
35        if include_designs:
36            best_design_rating = ProductionAI.cur_best_military_design_rating()
37            best_rating = max(best_rating, best_design_rating)
38        return best_rating
39    best_rating = 0.001
40    universe = fo.getUniverse()
41    aistate = get_aistate()
42    for fleet_id in FleetUtilsAI.get_empire_fleet_ids_by_role(MissionType.MILITARY):
43        fleet = universe.getFleet(fleet_id)
44        for ship_id in fleet.shipIDs:
45            ship_rating = CombatRatingsAI.ShipCombatStats(ship_id).get_rating(enemy_stats=aistate.get_standard_enemy())
46            best_rating = max(best_rating, ship_rating)
47    _best_ship_rating_cache[current_turn] = best_rating
48    if include_designs:
49        best_design_rating = ProductionAI.cur_best_military_design_rating()
50        best_rating = max(best_rating, best_design_rating)
51    return max(best_rating, 0.001)
52
53
54def get_preferred_max_military_portion_for_single_battle():
55    """
56    Determine and return the preferred max portion of military to be allocated to a single battle.
57
58    May be used to downgrade various possible actions requiring military support if they would require an excessive
59    allocation of military forces.  At the beginning of the game this max portion starts as 1.0, then is slightly
60    reduced to account for desire to reserve some defenses for other locations, and then in mid to late game, as the
61    size of the the military grows, this portion is further reduced to promote pursuit of multiple battlefronts in
62    parallel as opposed to single battlefronts against heavily defended positions.
63
64    :return: a number in range (0:1] for preferred max portion of military to be allocated to a single battle
65    :rtype: float
66    """
67    # TODO: this is a roughcut first pass, needs plenty of refinement
68    if fo.currentTurn() < 40:
69        return 1.0
70    best_ship_equivalents = (get_concentrated_tot_mil_rating() / cur_best_mil_ship_rating())**0.5
71    _MAX_SHIPS_BEFORE_PREFERRING_LESS_THAN_FULL_ENGAGEMENT = 3
72    if best_ship_equivalents <= _MAX_SHIPS_BEFORE_PREFERRING_LESS_THAN_FULL_ENGAGEMENT:
73        return 1.0
74    # the below ratio_exponent is still very much a work in progress.  It should probably be somewhere in the range of
75    # 0.2 to 0.5.  Values at the larger end will create a smaller expected battle size threshold that would
76    # cause the respective opportunity (invasion, colonization) scores to be discounted, thereby more quickly creating
77    # pressure for the AI to pursue multiple small/medium resistance fronts rather than pursuing a smaller number fronts
78    # facing larger resistance.  The AI will start facing some scoring pressure to not need to throw 100% of its
79    # military at a target as soon as its max military rating surpasses the equvalent of
80    # _MAX_SHIPS_BEFORE_PREFERRING_LESS_THAN_FULL_ENGAGEMENT of its best ships.  That starts simply as some scoring
81    # pressure to be able to hold back some small portion of its ships from the engagement, in order to be able to use
82    # them for defense or for other targets.  With an exponent value of 0.25, this would start creating substantial
83    # pressure against devoting more than half the military to a single target once the total military is somewhere
84    # above 18 best-ship equivalents, and pressure against deovting more than a third once the total is above about 80
85    # best-ship equivalents.  With an exponent value of 0.5, those thresholds would be 6 ships and 11 ships.  With the
86    # initial value of 0.35, those thresholds are about 10 ships and 25 ships.  Depending on how this return value is
87    # used, it should not prevent the more heavily fortified targets (and therefore discounted) from being taken
88    # if there are no more remaining easier targets available.
89    ratio_exponent = 0.35
90    return 1.0 / (best_ship_equivalents + 1 - _MAX_SHIPS_BEFORE_PREFERRING_LESS_THAN_FULL_ENGAGEMENT)**ratio_exponent
91
92
93def try_again(mil_fleet_ids, try_reset=False, thisround=""):
94    """Clear targets and orders for all specified fleets then call get_military_fleets again."""
95    aistate = get_aistate()
96    for fid in mil_fleet_ids:
97        mission = aistate.get_fleet_mission(fid)
98        mission.clear_fleet_orders()
99        mission.clear_target()
100    get_military_fleets(try_reset=try_reset, thisround=thisround)
101
102
103def avail_mil_needing_repair(mil_fleet_ids, split_ships=False, on_mission=False, repair_limit=0.70):
104    """Returns tuple of lists: (ids_needing_repair, ids_not)."""
105    fleet_buckets = [[], []]
106    universe = fo.getUniverse()
107    cutoff = [repair_limit, 0.25][on_mission]
108    aistate = get_aistate()
109    for fleet_id in mil_fleet_ids:
110        fleet = universe.getFleet(fleet_id)
111        ship_buckets = [[], []]
112        ships_cur_health = [0, 0]
113        ships_max_health = [0, 0]
114        for ship_id in fleet.shipIDs:
115            this_ship = universe.getShip(ship_id)
116            cur_struc = this_ship.initialMeterValue(fo.meterType.structure)
117            max_struc = this_ship.initialMeterValue(fo.meterType.maxStructure)
118            ship_ok = cur_struc >= cutoff * max_struc
119            ship_buckets[ship_ok].append(ship_id)
120            ships_cur_health[ship_ok] += cur_struc
121            ships_max_health[ship_ok] += max_struc
122        this_sys_id = fleet.systemID if fleet.nextSystemID == INVALID_ID else fleet.nextSystemID
123        fleet_ok = (sum(ships_cur_health) >= cutoff * sum(ships_max_health))
124        local_status = aistate.systemStatus.get(this_sys_id, {})
125        my_local_rating = combine_ratings(local_status.get('mydefenses', {}).get('overall', 0), local_status.get('myFleetRating', 0))
126        my_local_rating_vs_planets = local_status.get('myFleetRatingVsPlanets', 0)
127        combat_trigger = bool(local_status.get('fleetThreat', 0) or local_status.get('monsterThreat', 0))
128        if not combat_trigger and local_status.get('planetThreat', 0):
129            universe = fo.getUniverse()
130            system = universe.getSystem(this_sys_id)
131            for planet_id in system.planetIDs:
132                planet = universe.getPlanet(planet_id)
133                if planet.ownedBy(fo.empireID()):  # TODO: also exclude at-peace planets
134                    continue
135                if planet.unowned and not EspionageAI.colony_detectable_by_empire(planet_id, empire=fo.empireID()):
136                    continue
137                if sum([planet.currentMeterValue(meter_type) for meter_type in
138                        [fo.meterType.defense, fo.meterType.shield, fo.meterType.construction]]):
139                    combat_trigger = True
140                    break
141        needed_here = combat_trigger and local_status.get('totalThreat', 0) > 0  # TODO: assess if remaining other forces are sufficient
142        safely_needed = needed_here and my_local_rating > local_status.get('totalThreat', 0) and my_local_rating_vs_planets > local_status.get('planetThreat', 0)  # TODO: improve both assessment prongs
143        if not fleet_ok:
144            if safely_needed:
145                debug("Fleet %d at %s needs repair but deemed safely needed to remain for defense" % (fleet_id, universe.getSystem(fleet.systemID)))
146            else:
147                if needed_here:
148                    debug("Fleet %d at %s needed present for combat, but is damaged and deemed unsafe to remain." % (fleet_id, universe.getSystem(fleet.systemID)))
149                    debug("\t my_local_rating: %.1f ; threat: %.1f" % (my_local_rating, local_status.get('totalThreat', 0)))
150                debug("Selecting fleet %d at %s for repair" % (fleet_id, universe.getSystem(fleet.systemID)))
151        fleet_buckets[fleet_ok or bool(safely_needed)].append(fleet_id)
152    return fleet_buckets
153
154
155# TODO Move relevant initialization code from get_military_fleets into this class
156class AllocationHelper:
157
158    def __init__(self, already_assigned_rating, already_assigned_rating_vs_planets, available_rating, try_reset):
159        """
160        :param dict already_assigned_rating:
161        :param float available_rating:
162        """
163        self.try_reset = try_reset
164        self.allocations = []
165        self.allocation_by_groups = {}
166
167        self.available_rating = available_rating
168        self._remaining_rating = available_rating
169
170        self.threat_bias = 0.
171        self.safety_factor = get_aistate().character.military_safety_factor()
172
173        self.already_assigned_rating = dict(already_assigned_rating)
174        self.already_assigned_rating_vs_planets = dict(already_assigned_rating_vs_planets)
175        # store the number of empires which have supply or have supply within 2 jumps of the system
176        self.enemy_supply = {sys_id: min(2, len(enemies_nearly_supplying_system(sys_id)))
177                             for sys_id in fo.getUniverse().systemIDs}
178
179    @property
180    def remaining_rating(self):
181        return self._remaining_rating
182
183    @remaining_rating.setter
184    def remaining_rating(self, value):
185        self._remaining_rating = max(0, value)
186
187    def allocate(self, group, sys_id, min_rating, min_rating_vs_planets, take_any, max_rating):
188        tup = (sys_id, min_rating, min_rating_vs_planets, take_any, max_rating)
189        self.allocations.append(tup)
190        self.allocation_by_groups.setdefault(group, []).append(tup)
191        if self._remaining_rating <= min_rating:
192            self._remaining_rating = 0
193        else:
194            self._remaining_rating = rating_difference(self._remaining_rating, min_rating)
195
196
197class Allocator:
198    """
199    Base class for Military allocation for a single system.
200
201    The Allocator class and its subclasses are used to allocate
202    military resources for a single system. First, a minimum
203    and a maximum military rating are calculated which are
204    required / desired based on e.g. threat in the system.
205
206    An Allocator class defines if military resources should
207    be allocated even if the minimum requirements are not met
208    or if military resources are only allocated if the threshold
209    is passed.
210
211    The information is then passed to an AllocationHelper
212    instance. Allocating military resources by an Allocator
213    does not necessarily mean that military ships are actually
214    assigned to that system. It should be understood as a request
215    instead. If allocations of higher priority already use all the
216    available military resources, no ships can be sent.
217
218
219    Public methods:
220        :allocate(): Calculate the required/desired military resources
221                    for the system and enqueue the allocation info
222                    to the AllocationHelper.
223
224
225    Public attributes:
226        :ivar sys_id: ID of the system for which military resources are allocated
227
228
229    Example usage:
230        CapitelDefenseAllocator(capital_sys_id, allocation_helper).allocate()
231    """
232    _min_alloc_factor = 1.
233    _max_alloc_factor = 2.
234    _potential_threat_factor = 1.
235    _allocation_group = ''
236    _military_reset_ratio = -1  # if ratio of available to needed rating is smaller than this, then reset allocations
237
238    def __init__(self, sys_id, allocation_helper):
239        """
240        :param int sys_id: System for which military resources are allocated
241        :param AllocationHelper allocation_helper: The allocation helper where the information is to be stored.
242        """
243        self.sys_id = sys_id
244        self._allocation_helper = allocation_helper
245
246    def allocate(self):
247        """Calculate the desired allocation for this system and enqueue it in the allocation_helper."""
248        threat = self._calculate_threat()
249        min_alloc = self._minimum_allocation(threat)
250        max_alloc = self._maximum_allocation(threat)
251        alloc_vs_planets = self._allocation_vs_planets()
252        if min_alloc <= 0 and alloc_vs_planets <= 0:
253            # nothing to allocate here...
254            return
255        min_alloc = max(min_alloc, alloc_vs_planets)
256        max_alloc = max(max_alloc, alloc_vs_planets)
257
258        ratio = self._allocation_helper.remaining_rating / float(min_alloc)
259        if self._allocation_helper.remaining_rating > min_alloc or self._take_any():
260            self._allocation_helper.allocate(
261                group=self._allocation_group,
262                sys_id=self.sys_id,
263                min_rating=min(min_alloc, self._allocation_helper.remaining_rating),
264                min_rating_vs_planets=min(alloc_vs_planets, self._allocation_helper.remaining_rating),
265                take_any=self._take_any(),
266                max_rating=max_alloc,
267            )
268        if ratio < 1:
269            self._handle_not_enough_resources(ratio)
270
271    def _calculate_threat(self):
272        """
273        Calculate the required military rating in the system.
274
275        The value calculated does not have to represent a tangible
276        threat / enemy force. It only provides a measurement how much
277        military should be sent to the system. Deriving further conditions
278        for military presence and translating them into an equivalent
279        military rating is strongly encouraged.
280        It is implied however, that the value calculated here should
281        be greater than or at least equal to the actual visible strength
282        of enemy forces within the system so that a subsequent military
283        mission can be successful.
284
285        :return: Equivalent military rating required in the system
286        :rtype: float
287        """
288        raise NotImplementedError
289
290    def _minimum_allocation(self, threat):
291        """
292        Calculate the minimum allocation for the system.
293
294        The default minimum allocation is the missing forces
295        to obtain a rating given by the threat weighted with
296        the subclass' *min_alloc_factor*.
297        Existing military missions are considered.
298
299        Subclasses may choose to override this method and
300        implement a different logic.
301
302        :param float threat: threat as calculated by _calculate_threat()
303        :rtype: float
304        """
305        return CombatRatingsAI.rating_needed(
306            self._min_alloc_factor * threat,
307            self.assigned_rating)
308
309    def _maximum_allocation(self, threat):
310        """
311        Calculate the maximum allocation for the system.
312
313        The default maximum allocation is the missing forces
314        to obtain a rating given by the threat weighted with
315        the subclass' *max_alloc_factor*.
316        Existing military missions are considered.
317
318        Subclasses may choose to override this method and
319        implement a different logic.
320
321        :param float threat:
322        :rtype: float
323        """
324        return CombatRatingsAI.rating_needed(
325                self._max_alloc_factor * threat,
326                self.assigned_rating)
327
328    def _allocation_vs_planets(self):
329        return CombatRatingsAI.rating_needed(
330            self.safety_factor * self._planet_threat(),
331            self.assigned_rating_vs_planets)
332
333    def _take_any(self):
334        """
335        If true, forces smaller than the minimum allocation are accepted.
336
337        :rtype: bool
338        """
339        raise NotImplementedError
340
341    def _handle_not_enough_resources(self, ratio):
342        """Called if minimum allocation is larget than available resources.
343
344        High priority subclasses are expected to throw a ReleaseMilitaryException
345        which should be caught from the caller of allocate() and trigger the
346        release of all military resources so they can be reassigned to higher
347        priority targets.
348
349        :param float ratio: ratio of available resources to minimum allocation
350        """
351        if ratio < self._military_reset_ratio and self._allocation_helper.try_reset:
352            raise ReleaseMilitaryException
353
354    @property
355    def nearby_empire_count(self):
356        """The number of enemy empires within at most 2 jumps."""
357        return self._allocation_helper.enemy_supply.get(self.sys_id, 0)
358
359    @property
360    def threat_bias(self):
361        """A constant threat biases added additively to the calculated threat."""
362        return self._allocation_helper.threat_bias
363
364    @property
365    def safety_factor(self):
366        """A multiplicative factor for threat calculations"""
367        return self._allocation_helper.safety_factor
368
369    @property
370    def assigned_rating(self):
371        """The combined rating of existing missions assigned to the system."""
372        return self._allocation_helper.already_assigned_rating.get(self.sys_id, 0)
373
374    @property
375    def assigned_rating_vs_planets(self):
376        return self._allocation_helper.already_assigned_rating_vs_planets.get(self.sys_id, 0)
377
378    def _local_threat(self):
379        """Military rating of enemies present in the system."""
380        return get_system_local_threat(self.sys_id)
381
382    def _neighbor_threat(self):
383        """Military rating of enemies present in neighboring system."""
384        return get_system_neighbor_threat(self.sys_id)
385
386    def _jump2_threat(self):
387        """Military rating of enemies present 2 jumps away from the system."""
388        return get_system_jump2_threat(self.sys_id)
389
390    def _potential_threat(self):
391        """Number of nearby enemies times the average enemy rating weighted by _potential_threat_factor"""
392        return self.nearby_empire_count * enemy_rating() * self._potential_threat_factor
393
394    def _regional_threat(self):
395        """Threat derived from enemy supply lanes."""
396        return get_system_regional_threat(self.sys_id)
397
398    def _potential_support(self):
399        """Military rating of our forces in neighboring systems."""
400        return get_system_neighbor_support(self.sys_id)
401
402    def _planet_threat(self):
403        return get_system_planetary_threat(self.sys_id)
404
405    def _enemy_ship_count(self):
406        return get_aistate().systemStatus.get(self.sys_id, {}).get('enemy_ship_count', 0.)
407
408
409class CapitalDefenseAllocator(Allocator):
410
411    _allocation_group = 'capitol'
412    _military_reset_ratio = 0.5
413
414    def _minimum_allocation(self, threat):
415        nearby_forces = CombatRatingsAI.combine_ratings(
416                self.assigned_rating, self._potential_support())
417        return max(
418                CombatRatingsAI.rating_needed(self._regional_threat(), nearby_forces),
419                CombatRatingsAI.rating_needed(1.4*threat, self.assigned_rating))
420
421    def _maximum_allocation(self, threat):
422        return max(
423                CombatRatingsAI.rating_needed(1.5*self._regional_threat(), self.assigned_rating),
424                CombatRatingsAI.rating_needed(2*threat, self.assigned_rating))
425
426    def _calculate_threat(self):
427        potential_threat = max(self._potential_threat() - self._potential_support(), 0)
428        actual_threat = self.safety_factor * (
429            2*self.threat_bias +
430            + CombatRatingsAI.combine_ratings(self._local_threat(), self._neighbor_threat()))
431        return potential_threat + actual_threat
432
433    def _take_any(self):
434        return True
435
436
437class PlanetDefenseAllocator(Allocator):
438
439    _allocation_group = 'occupied'
440    _min_alloc_factor = 1.1
441    _max_alloc_factor = 1.5
442    _potential_threat_factor = 0.5
443    _military_reset_ratio = 0.8
444
445    def allocate(self):
446        remaining_rating = self._allocation_helper.remaining_rating
447        if remaining_rating > 0:
448            super(PlanetDefenseAllocator, self).allocate()
449            return
450        if self._minimum_allocation(self._calculate_threat()):
451            pass  # raise ReleaseMilitaryException TODO
452
453    def _minimum_allocation(self, threat):
454        super_call = super(PlanetDefenseAllocator, self)._minimum_allocation(threat)
455        restriction = 0.5 * self._allocation_helper.available_rating
456        return min(super_call, restriction)
457
458    def _calculate_threat(self):
459        nearby_forces = CombatRatingsAI.combine_ratings(
460            self._potential_support(), self.assigned_rating)
461        return (
462            self.threat_bias +
463            + self.safety_factor * CombatRatingsAI.combine_ratings(self._local_threat(),
464                                                                   self._neighbor_threat()) +
465            + max(0., self._potential_threat() + self._jump2_threat() - nearby_forces))
466
467    def _take_any(self):
468        return True
469
470
471class TargetAllocator(Allocator):
472
473    _allocation_group = 'otherTargets'
474    _min_alloc_factor = 1.3
475    _max_alloc_factor = 2.5
476    _potential_threat_factor = 0.5
477
478    def _calculate_threat(self):
479        return (
480            self.threat_bias +
481            + self.safety_factor * CombatRatingsAI.combine_ratings_list([
482                self._local_threat(),
483                .75 * self._neighbor_threat(),
484                .5 * self._jump2_threat()])
485            + self._potential_threat())
486
487    def _take_any(self):
488        return self.assigned_rating > 0
489
490    def _planet_threat_multiplier(self):
491        # to the extent that enemy planetary defenses are bolstered by fleet defenses, a smaller portion of our
492        # attacks will land on the planet and hence we need a greater portion of planet-effective attacks.  One
493        # desired characteristic of the following planet_threat_multiplier is that if the entire local threat is
494        # due to planetary threat then we want the multiplier to be unity.  Furthermore, the more enemy ships are
495        # present, the smaller proportion of our attacks would be directed against the enemy planet.  The following is
496        # just one of many forms of calculation that might work reasonably.
497        # TODO: assess and revamp the planet_threat_multiplier calculation
498        return ((self._enemy_ship_count() + self._local_threat()/self._planet_threat())**0.5
499                if self._planet_threat() > 0 else 1.0)
500
501    def _allocation_vs_planets(self):
502        return CombatRatingsAI.rating_needed(
503            self.safety_factor*self._planet_threat_multiplier()*self._planet_threat(),
504            self.assigned_rating_vs_planets)
505
506
507class TopTargetAllocator(TargetAllocator):
508    _allocation_group = 'topTargets'
509    _max_alloc_factor = 3
510
511
512class OutpostTargetAllocator(TargetAllocator):
513    _max_alloc_factor = 3
514
515
516class BlockadeAllocator(TargetAllocator):
517    _potential_threat_factor = 0.25
518    _max_alloc_factor = 1.5
519
520    def _maximum_allocation(self, threat):
521        return min(self._minimum_allocation(threat), self._allocation_helper.remaining_rating) * self._max_alloc_factor
522
523
524class LocalThreatAllocator(Allocator):
525    _potential_threat_factor = 0
526    _min_alloc_factor = 1.3
527    _max_alloc_factor = 2
528    _allocation_group = 'otherTargets'
529
530    def _calculate_threat(self):
531
532        systems_status = get_aistate().systemStatus.get(self.sys_id, {})
533        threat = self.safety_factor * CombatRatingsAI.combine_ratings(systems_status.get('fleetThreat', 0),
534                                                                      systems_status.get('monsterThreat', 0) +
535                                                                      + systems_status.get('planetThreat', 0))
536
537        return self.threat_bias + threat
538
539    def _take_any(self):
540        return False
541
542
543class InteriorTargetsAllocator(LocalThreatAllocator):
544    _max_alloc_factor = 2.5
545    _min_alloc_factor = 1.3
546
547    def _calculate_threat(self):
548        return self.threat_bias + self.safety_factor * self._local_threat()
549
550    def _maximum_allocation(self, threat):
551        return self._max_alloc_factor * min(self._minimum_allocation(threat), self._allocation_helper.remaining_rating)
552
553    def _take_any(self):
554        return self.assigned_rating > 0
555
556
557class ExplorationTargetAllocator(LocalThreatAllocator):
558    _potential_threat_factor = 0.25
559    _max_alloc_factor = 2.0
560    _allocation_group = 'exploreTargets'
561
562    def _calculate_threat(self):
563        return self.safety_factor * self._local_threat() + self._potential_threat()
564
565    def _take_any(self):
566        return False
567
568
569class BorderSecurityAllocator(LocalThreatAllocator):
570    _min_alloc_factor = 1.2
571    _max_alloc_factor = 2
572    _allocation_group = 'accessibleTargets'
573
574    def __init__(self, sys_id, allocation_helper):
575        super(BorderSecurityAllocator, self).__init__(sys_id, allocation_helper)
576
577    def _maximum_allocation(self, threat):
578        return self._max_alloc_factor * self.safety_factor * max(self._local_threat(), self._neighbor_threat())
579
580
581class ReleaseMilitaryException(Exception):
582    pass
583
584
585# TODO: May want to move these functions into AIstate class
586def get_system_local_threat(sys_id):
587    return get_aistate().systemStatus.get(sys_id, {}).get('totalThreat', 0.)
588
589
590def get_system_jump2_threat(sys_id):
591    return get_aistate().systemStatus.get(sys_id, {}).get('jump2_threat', 0.)
592
593
594def get_system_neighbor_support(sys_id):
595    return get_aistate().systemStatus.get(sys_id, {}).get('my_neighbor_rating', 0.)
596
597
598def get_system_neighbor_threat(sys_id):
599    return get_aistate().systemStatus.get(sys_id, {}).get('neighborThreat', 0.)
600
601
602def get_system_regional_threat(sys_id):
603    return get_aistate().systemStatus.get(sys_id, {}).get('regional_threat', 0.)
604
605
606def get_system_planetary_threat(sys_id):
607    return get_aistate().systemStatus.get(sys_id, {}).get('planetThreat', 0.)
608
609
610def enemy_rating():
611    """:rtype: float"""
612    return get_aistate().empire_standard_enemy_rating
613
614
615def get_my_defense_rating_in_system(sys_id):
616    return get_aistate().systemStatus.get(sys_id, {}).get('mydefenses', {}).get('overall')
617
618
619def enemies_nearly_supplying_system(sys_id):
620    return get_aistate().systemStatus.get(sys_id, {}).get('enemies_nearly_supplied', [])
621
622
623def get_military_fleets(mil_fleets_ids=None, try_reset=True, thisround="Main"):
624    """Get armed military fleets."""
625    global _military_allocations
626
627    universe = fo.getUniverse()
628    empire_id = fo.empireID()
629    home_system_id = PlanetUtilsAI.get_capital_sys_id()
630
631    all_military_fleet_ids = (mil_fleets_ids if mil_fleets_ids is not None
632                              else FleetUtilsAI.get_empire_fleet_ids_by_role(MissionType.MILITARY))
633
634    # Todo: This block had been originally added to address situations where fleet missions were not properly
635    #  terminating, leaving fleets stuck in stale deployments. Assess if this block is still needed at all; delete
636    #  if not, otherwise restructure the following code so that in event a reset is occurring greater priority is given
637    #  to providing military support to locations where a necessary Secure mission might have just been released (i.e.,
638    #  at invasion and colony/outpost targets where the troopships and colony ships are on their way), or else allow
639    #  only a partial reset which does not reset Secure missions.
640    enable_periodic_mission_reset = False
641    if enable_periodic_mission_reset and try_reset and (fo.currentTurn() + empire_id) % 30 == 0 and thisround == "Main":
642        debug("Resetting all Military missions as part of an automatic periodic reset to clear stale missions.")
643        try_again(all_military_fleet_ids, try_reset=False, thisround=thisround + " Reset")
644        return
645
646    mil_fleets_ids = list(FleetUtilsAI.extract_fleet_ids_without_mission_types(all_military_fleet_ids))
647    mil_needing_repair_ids, mil_fleets_ids = avail_mil_needing_repair(mil_fleets_ids, split_ships=True)
648    avail_mil_rating = combine_ratings_list(CombatRatingsAI.get_fleet_rating(x) for x in mil_fleets_ids)
649
650    if not mil_fleets_ids:
651        if "Main" in thisround:
652            _military_allocations = []
653        return []
654
655    # for each system, get total rating of fleets assigned to it
656    already_assigned_rating = {}
657    already_assigned_rating_vs_planets = {}
658    aistate = get_aistate()
659    systems_status = aistate.systemStatus
660    enemy_sup_factor = {}  # enemy supply
661    for sys_id in universe.systemIDs:
662        already_assigned_rating[sys_id] = 0
663        already_assigned_rating_vs_planets[sys_id] = 0
664        enemy_sup_factor[sys_id] = min(2, len(systems_status.get(sys_id, {}).get('enemies_nearly_supplied', [])))
665    for fleet_id in [fid for fid in all_military_fleet_ids if fid not in mil_fleets_ids]:
666        ai_fleet_mission = aistate.get_fleet_mission(fleet_id)
667        if not ai_fleet_mission.target:  # shouldn't really be possible
668            continue
669        last_sys = ai_fleet_mission.target.get_system().id  # will count this fleet as assigned to last system in target list  # TODO last_sys or target sys?
670        this_rating = CombatRatingsAI.get_fleet_rating(fleet_id)
671        this_rating_vs_planets = CombatRatingsAI.get_fleet_rating_against_planets(fleet_id)
672        already_assigned_rating[last_sys] = CombatRatingsAI.combine_ratings(
673                already_assigned_rating.get(last_sys, 0), this_rating)
674        already_assigned_rating_vs_planets[last_sys] = CombatRatingsAI.combine_ratings(
675                already_assigned_rating_vs_planets.get(last_sys, 0), this_rating_vs_planets)
676    for sys_id in universe.systemIDs:
677        my_defense_rating = systems_status.get(sys_id, {}).get('mydefenses', {}).get('overall', 0)
678        already_assigned_rating[sys_id] = CombatRatingsAI.combine_ratings(my_defense_rating, already_assigned_rating[sys_id])
679        if _verbose_mil_reporting and already_assigned_rating[sys_id]:
680            debug("\t System %s already assigned rating %.1f" % (
681                universe.getSystem(sys_id), already_assigned_rating[sys_id]))
682
683    # get systems to defend
684    capital_id = PlanetUtilsAI.get_capital()
685    if capital_id is not None:
686        capital_planet = universe.getPlanet(capital_id)
687    else:
688        capital_planet = None
689    # TODO: if no owned planets try to capture one!
690    if capital_planet:
691        capital_sys_id = capital_planet.systemID
692    else:  # should be rare, but so as to not break code below, pick a randomish mil-centroid system
693        capital_sys_id = None  # unless we can find one to use
694        system_dict = {}
695        for fleet_id in all_military_fleet_ids:
696            status = aistate.fleetStatus.get(fleet_id, None)
697            if status is not None:
698                system_id = status['sysID']
699                if not list(universe.getSystem(system_id).planetIDs):
700                    continue
701                system_dict[system_id] = system_dict.get(system_id, 0) + status.get('rating', 0)
702        ranked_systems = sorted([(val, sys_id) for sys_id, val in system_dict.items()])
703        if ranked_systems:
704            capital_sys_id = ranked_systems[-1][-1]
705        else:
706            try:
707                capital_sys_id = next(iter(aistate.fleetStatus.items()))[1]['sysID']
708            except:  # noqa: E722
709                pass
710
711    num_targets = max(10, PriorityAI.allotted_outpost_targets)
712    top_target_planets = ([pid for pid, pscore, trp in AIstate.invasionTargets[:PriorityAI.allotted_invasion_targets()]
713                           if pscore > InvasionAI.MIN_INVASION_SCORE] +
714                          [pid for pid, (pscore, spec) in list(aistate.colonisableOutpostIDs.items())[:num_targets]
715                           if pscore > InvasionAI.MIN_INVASION_SCORE] +
716                          [pid for pid, (pscore, spec) in list(aistate.colonisablePlanetIDs.items())[:num_targets]
717                           if pscore > InvasionAI.MIN_INVASION_SCORE])
718    top_target_planets.extend(aistate.qualifyingTroopBaseTargets.keys())
719
720    base_col_target_systems = PlanetUtilsAI.get_systems(top_target_planets)
721    top_target_systems = []
722    for sys_id in AIstate.invasionTargetedSystemIDs + base_col_target_systems:
723        if sys_id not in top_target_systems:
724            if aistate.systemStatus[sys_id]['totalThreat'] > get_tot_mil_rating():
725                continue
726            top_target_systems.append(sys_id)  # doing this rather than set, to preserve order
727
728    try:
729        # capital defense
730        allocation_helper = AllocationHelper(already_assigned_rating, already_assigned_rating_vs_planets, avail_mil_rating, try_reset)
731        if capital_sys_id is not None:
732            CapitalDefenseAllocator(capital_sys_id, allocation_helper).allocate()
733
734        # defend other planets
735        empire_planet_ids = PlanetUtilsAI.get_owned_planets_by_empire(universe.planetIDs)
736        empire_occupied_system_ids = list(set(PlanetUtilsAI.get_systems(empire_planet_ids)) - {capital_sys_id})
737        for sys_id in empire_occupied_system_ids:
738            PlanetDefenseAllocator(sys_id, allocation_helper).allocate()
739
740        # attack / protect high priority targets
741        for sys_id in top_target_systems:
742            TopTargetAllocator(sys_id, allocation_helper).allocate()
743
744        # enemy planets
745        other_targeted_system_ids = [sys_id for sys_id in set(PlanetUtilsAI.get_systems(AIstate.opponentPlanetIDs)) if
746                                     sys_id not in top_target_systems]
747        for sys_id in other_targeted_system_ids:
748            TargetAllocator(sys_id, allocation_helper).allocate()
749
750        # colony / outpost targets
751        other_targeted_system_ids = [sys_id for sys_id in
752                                     list(set(AIstate.colonyTargetedSystemIDs + AIstate.outpostTargetedSystemIDs)) if
753                                     sys_id not in top_target_systems]
754        for sys_id in other_targeted_system_ids:
755            OutpostTargetAllocator(sys_id, allocation_helper).allocate()
756
757        # TODO blockade enemy systems
758
759        # interior systems
760        targetable_ids = set(state.get_systems_by_supply_tier(0))
761        current_mil_systems = [sid for sid, _, _, _, _ in allocation_helper.allocations]
762        interior_targets1 = targetable_ids.difference(current_mil_systems)
763        interior_targets = [sid for sid in interior_targets1 if (
764            allocation_helper.threat_bias + systems_status.get(sid, {}).get('totalThreat', 0) > 0.8 * allocation_helper.already_assigned_rating[sid])]
765        for sys_id in interior_targets:
766            InteriorTargetsAllocator(sys_id, allocation_helper).allocate()
767
768        # TODO Exploration targets
769
770        # border protections
771        visible_system_ids = aistate.visInteriorSystemIDs | aistate.visBorderSystemIDs
772        accessible_system_ids = ([sys_id for sys_id in visible_system_ids if
773                                 universe.systemsConnected(sys_id, home_system_id, empire_id)]
774                                 if home_system_id != INVALID_ID else [])
775        current_mil_systems = [sid for sid, alloc, rvp, take_any, _ in allocation_helper.allocations if alloc > 0]
776        border_targets1 = [sid for sid in accessible_system_ids if sid not in current_mil_systems]
777        border_targets = [sid for sid in border_targets1 if (
778            allocation_helper.threat_bias + systems_status.get(sid, {}).get('fleetThreat', 0) + systems_status.get(sid, {}).get(
779                    'planetThreat', 0) > 0.8 * allocation_helper.already_assigned_rating[sid])]
780        for sys_id in border_targets:
781            BorderSecurityAllocator(sys_id, allocation_helper).allocate()
782    except ReleaseMilitaryException:
783        try_again(all_military_fleet_ids)
784        return
785
786    new_allocations = []
787    remaining_mil_rating = avail_mil_rating
788    # for top categories assign max_alloc right away as available
789    for cat in ['capitol', 'occupied', 'topTargets']:
790        for sid, alloc, rvp, take_any, max_alloc in allocation_helper.allocation_by_groups.get(cat, []):
791            if remaining_mil_rating <= 0:
792                break
793            this_alloc = min(remaining_mil_rating, max_alloc)
794            new_allocations.append((sid, this_alloc, alloc, rvp, take_any))
795            remaining_mil_rating = rating_difference(remaining_mil_rating,  this_alloc)
796
797    base_allocs = set()
798    # for lower priority categories, first assign base_alloc around to all, then top up as available
799    for cat in ['otherTargets', 'accessibleTargets', 'exploreTargets']:
800        for sid, alloc, rvp, take_any, max_alloc in allocation_helper.allocation_by_groups.get(cat, []):
801            if remaining_mil_rating <= 0:
802                break
803            alloc = min(remaining_mil_rating, alloc)
804            base_allocs.add(sid)
805            remaining_mil_rating = rating_difference(remaining_mil_rating,  alloc)
806    for cat in ['otherTargets', 'accessibleTargets', 'exploreTargets']:
807        for sid, alloc, rvp, take_any, max_alloc in allocation_helper.allocation_by_groups.get(cat, []):
808            if sid not in base_allocs:
809                break
810            if remaining_mil_rating <= 0:
811                new_allocations.append((sid, alloc, alloc, rvp, take_any))
812            else:
813                local_max_avail = combine_ratings(remaining_mil_rating, alloc)
814                new_rating = min(local_max_avail, max_alloc)
815                new_allocations.append((sid, new_rating, alloc, rvp, take_any))
816                remaining_mil_rating = rating_difference(local_max_avail, new_rating)
817
818    if "Main" in thisround:
819        _military_allocations = new_allocations
820    if _verbose_mil_reporting or "Main" in thisround:
821        debug("------------------------------\nFinal %s Round Military Allocations: %s \n-----------------------" % (thisround, dict([(sid, alloc) for sid, alloc, _, _, _ in new_allocations])))
822        debug("(Apparently) remaining military rating: %.1f" % remaining_mil_rating)
823
824    return new_allocations
825
826
827def assign_military_fleets_to_systems(use_fleet_id_list=None, allocations=None, round=1):
828    # assign military fleets to military theater systems
829    global _military_allocations
830    universe = fo.getUniverse()
831    if allocations is None:
832        allocations = []
833
834    doing_main = (use_fleet_id_list is None)
835    aistate = get_aistate()
836    if doing_main:
837        aistate.misc['ReassignedFleetMissions'] = []
838        base_defense_ids = FleetUtilsAI.get_empire_fleet_ids_by_role(MissionType.ORBITAL_DEFENSE)
839        unassigned_base_defense_ids = FleetUtilsAI.extract_fleet_ids_without_mission_types(base_defense_ids)
840        for fleet_id in unassigned_base_defense_ids:
841            fleet = universe.getFleet(fleet_id)
842            if not fleet:
843                continue
844            sys_id = fleet.systemID
845            target = TargetSystem(sys_id)
846            fleet_mission = aistate.get_fleet_mission(fleet_id)
847            fleet_mission.clear_fleet_orders()
848            fleet_mission.clear_target()
849            mission_type = MissionType.ORBITAL_DEFENSE
850            fleet_mission.set_target(mission_type, target)
851
852        all_military_fleet_ids = FleetUtilsAI.get_empire_fleet_ids_by_role(MissionType.MILITARY)
853        if not all_military_fleet_ids:
854            _military_allocations = []
855            return
856        avail_mil_fleet_ids = list(FleetUtilsAI.extract_fleet_ids_without_mission_types(all_military_fleet_ids))
857        mil_needing_repair_ids, avail_mil_fleet_ids = avail_mil_needing_repair(avail_mil_fleet_ids)
858        these_allocations = _military_allocations
859        debug("==================================================")
860        debug("Assigning military fleets")
861        debug("---------------------------------")
862    else:
863        avail_mil_fleet_ids = list(use_fleet_id_list)
864        mil_needing_repair_ids, avail_mil_fleet_ids = avail_mil_needing_repair(avail_mil_fleet_ids)
865        these_allocations = allocations
866
867    # send_for_repair(mil_needing_repair_ids) #currently, let get taken care of by AIFleetMission.generate_fleet_orders()
868
869    # get systems to defend
870
871    avail_mil_fleet_ids = set(avail_mil_fleet_ids)
872    for sys_id, alloc, minalloc, rvp, takeAny in these_allocations:
873        if not doing_main and not avail_mil_fleet_ids:
874            break
875        debug("Allocating for: %s", TargetSystem(sys_id))
876        found_fleets = []
877        found_stats = {}
878        ensure_return = sys_id not in set(AIstate.colonyTargetedSystemIDs
879                                          + AIstate.outpostTargetedSystemIDs
880                                          + AIstate.invasionTargetedSystemIDs)
881        these_fleets = FleetUtilsAI.get_fleets_for_mission(
882            target_stats={'rating': alloc, 'ratingVsPlanets': rvp, 'target_system': TargetSystem(sys_id)},
883            min_stats={'rating': minalloc, 'ratingVsPlanets': rvp, 'target_system': TargetSystem(sys_id)},
884            cur_stats=found_stats, starting_system=sys_id, fleet_pool_set=avail_mil_fleet_ids,
885            fleet_list=found_fleets, ensure_return=ensure_return)
886        if not these_fleets:
887            debug("Could not allocate any fleets.")
888            if not found_fleets or not (FleetUtilsAI.stats_meet_reqs(found_stats, {'rating': minalloc}) or takeAny):
889                if doing_main:
890                    if _verbose_mil_reporting:
891                        debug("NO available/suitable military allocation for system %d ( %s ) "
892                              "-- requested allocation %8d, found available rating %8d in fleets %s"
893                              % (sys_id, universe.getSystem(sys_id).name, minalloc,
894                                 found_stats.get('rating', 0), found_fleets))
895                avail_mil_fleet_ids.update(found_fleets)
896                continue
897            else:
898                these_fleets = found_fleets
899        else:
900            debug("Assigning fleets %s to target %s", these_fleets, TargetSystem(sys_id))
901            if doing_main and _verbose_mil_reporting:
902                debug("FULL+ military allocation for system %d ( %s )"
903                      " -- requested allocation %8d, got %8d with fleets %s"
904                      % (sys_id, universe.getSystem(sys_id).name, alloc, found_stats.get('rating', 0), these_fleets))
905        target = TargetSystem(sys_id)
906        for fleet_id in these_fleets:
907            fo.issueAggressionOrder(fleet_id, True)
908            fleet_mission = aistate.get_fleet_mission(fleet_id)
909            fleet_mission.clear_fleet_orders()
910            fleet_mission.clear_target()
911            if sys_id in set(AIstate.colonyTargetedSystemIDs + AIstate.outpostTargetedSystemIDs + AIstate.invasionTargetedSystemIDs):
912                mission_type = MissionType.SECURE
913            elif state.get_empire_planets_by_system(sys_id):
914                mission_type = MissionType.PROTECT_REGION
915            else:
916                mission_type = MissionType.MILITARY
917            fleet_mission.set_target(mission_type, target)
918            fleet_mission.generate_fleet_orders()
919            if not doing_main:
920                aistate.misc.setdefault('ReassignedFleetMissions', []).append(fleet_mission)
921
922    if doing_main:
923        debug("---------------------------------")
924    last_round = 3
925    last_round_name = "LastRound"
926    if round <= last_round:
927        # check if any fleets remain unassigned
928        all_military_fleet_ids = FleetUtilsAI.get_empire_fleet_ids_by_role(MissionType.MILITARY)
929        avail_mil_fleet_ids = list(FleetUtilsAI.extract_fleet_ids_without_mission_types(all_military_fleet_ids))
930        allocations = []
931        round += 1
932        thisround = "Extras Remaining Round %d" % round if round < last_round else last_round_name
933        if avail_mil_fleet_ids:
934            debug("Round %s - still have available military fleets: %s", thisround, avail_mil_fleet_ids)
935            allocations = get_military_fleets(mil_fleets_ids=avail_mil_fleet_ids, try_reset=False, thisround=thisround)
936        if allocations:
937            assign_military_fleets_to_systems(use_fleet_id_list=avail_mil_fleet_ids, allocations=allocations, round=round)
938        else:
939            # assign remaining fleets to nearest systems to protect.
940            all_military_fleet_ids = FleetUtilsAI.get_empire_fleet_ids_by_role(MissionType.MILITARY)
941            avail_mil_fleet_ids = list(FleetUtilsAI.extract_fleet_ids_without_mission_types(all_military_fleet_ids))
942
943            def system_score(_fid, _sys_id):
944                """Helper function to rank systems by priority"""
945                jump_distance = universe.jumpDistance(_fid, _sys_id)
946                if get_system_local_threat(_sys_id):
947                    weight = 10
948                elif get_system_neighbor_threat(_sys_id):
949                    weight = 3
950                elif get_system_jump2_threat(_sys_id):
951                    weight = 1
952                else:
953                    weight = 1 / max(.5, float(state.get_distance_to_enemy_supply(_sys_id)))**1.25
954                return float(weight) / (jump_distance+1)
955
956            for fid in avail_mil_fleet_ids:
957                fleet = universe.getFleet(fid)
958                FleetUtilsAI.get_fleet_system(fleet)
959                systems = state.get_empire_planets_by_system().keys()
960                if not systems:
961                    continue
962                sys_id = max(systems, key=lambda x: system_score(fid, x))
963
964                debug("Assigning leftover %s to system %d "
965                      "- nothing better to do.", fleet, sys_id)
966
967                fleet_mission = aistate.get_fleet_mission(fid)
968                fleet_mission.clear_fleet_orders()
969                target_system = TargetSystem(sys_id)
970                fleet_mission.set_target(MissionType.PROTECT_REGION, target_system)
971                fleet_mission.generate_fleet_orders()
972
973
974@cache_by_turn_persistent
975def get_tot_mil_rating():
976    """
977    Give an assessment of total military rating considering all fleets as if distributed to separate systems.
978
979    :return: a military rating value
980    :rtype: float
981    """
982    return sum(CombatRatingsAI.get_fleet_rating(fleet_id)
983               for fleet_id in FleetUtilsAI.get_empire_fleet_ids_by_role(MissionType.MILITARY))
984
985
986@cache_by_turn_persistent
987def get_concentrated_tot_mil_rating():
988    """
989    Give an assessment of total military rating as if all fleets were merged into a single mega-fleet.
990
991    :return: a military rating value
992    :rtype: float
993    """
994    return CombatRatingsAI.combine_ratings_list([CombatRatingsAI.get_fleet_rating(fleet_id) for fleet_id in
995                                                 FleetUtilsAI.get_empire_fleet_ids_by_role(MissionType.MILITARY)])
996
997
998@cache_by_turn_persistent
999def get_num_military_ships():
1000    fleet_status = get_aistate().fleetStatus
1001    return sum(fleet_status.get(fid, {}).get('nships', 0)
1002               for fid in FleetUtilsAI.get_empire_fleet_ids_by_role(MissionType.MILITARY))
1003
1004
1005def get_military_fleets_with_target_system(target_system_id):
1006    military_mission_types = [MissionType.MILITARY,  MissionType.SECURE]
1007    found_fleets = []
1008    for fleet_mission in get_aistate().get_fleet_missions_with_any_mission_types(military_mission_types):
1009        if fleet_mission.target and fleet_mission.target.id == target_system_id:
1010            found_fleets.append(fleet_mission.fleet.id)
1011    return found_fleets
1012