1from collections import Counter
2from functools import reduce
3from logging import warning, error
4
5import freeOrionAIInterface as fo
6import FleetUtilsAI
7from aistate_interface import get_aistate
8from EnumsAI import MissionType
9from freeorion_tools import dict_to_tuple, tuple_to_dict, cache_for_current_turn
10from ShipDesignAI import get_ship_part
11from AIDependencies import INVALID_ID, CombatTarget
12
13
14_issued_errors = []
15
16
17def get_allowed_targets(partname: str) -> int:
18    """Return the allowed targets for a given hangar or shortrange weapon"""
19    try:
20        return CombatTarget.PART_ALLOWED_TARGETS[partname]
21    except KeyError:
22        if partname not in _issued_errors:
23            error("AI has no targeting information for weapon part %s. Will assume any target allowed."
24                  "Please update CombatTarget.PART_ALLOWED_TARGETS in AIDependencies.py ")
25            _issued_errors.append(partname)
26        return CombatTarget.ANY
27
28
29@cache_for_current_turn
30def get_empire_standard_fighter():
31    """Get the current empire standard fighter stats, i.e. the most common shiptype within the empire.
32
33    :return: Stats of most common fighter in the empire
34    :rtype: ShipCombatStats
35    """
36    stats_dict = Counter()
37    for fleet_id in FleetUtilsAI.get_empire_fleet_ids_by_role(MissionType.MILITARY):
38        ship_stats = FleetCombatStats(fleet_id, consider_refuel=True).get_ship_combat_stats()
39        stats_dict.update(ship_stats)
40
41    most_commons = stats_dict.most_common(1)
42    if most_commons:
43        return most_commons[0][0]
44    else:
45        return default_ship_stats()
46
47
48def default_ship_stats():
49    """ Return some ship stats to assume if no other intel is available.
50
51    :return: Some weak standard ship
52    :rtype: ShipCombatStats
53    """
54    attacks = (6.0, 1)
55    structure = 15
56    shields = 0
57    fighters = 0
58    launch_rate = 0
59    fighter_damage = 0
60    flak_shots = 0
61    has_interceptors = False
62    damage_vs_planets = 0
63    has_bomber = False
64    return ShipCombatStats(stats=(attacks, structure, shields,
65                                  fighters, launch_rate, fighter_damage,
66                                  flak_shots, has_interceptors,
67                                  damage_vs_planets, has_bomber))
68
69
70class ShipCombatStats:
71    """Stores all relevant stats of a ship for combat strength evaluation."""
72    class BasicStats:
73        """Stores non-fighter-related stats."""
74        def __init__(self, attacks, structure, shields):
75            """
76
77            :param attacks:
78            :type attacks: dict[float, int]|None
79            :param structure:
80            :type structure: int|None
81            :param shields:
82            :type shields: int|None
83            :return:
84            """
85            self.structure = 1.0 if structure is None else structure
86            self.shields = 0.0 if shields is None else shields
87            self.attacks = {} if attacks is None else tuple_to_dict(attacks)  # type: dict[float, int]
88
89        def get_stats(self, hashable=False):
90            """
91
92            :param hashable: if hashable, return tuple instead of attacks-dict
93            :return: attacks, structure, shields
94            """
95            if not hashable:
96                return self.attacks, self.structure, self.shields
97            else:
98                return dict_to_tuple(self.attacks), self.structure, self.shields
99
100        def __str__(self):
101            return str(self.get_stats())
102
103    class FighterStats:
104        """ Stores fighter-related stats """
105        def __init__(self, capacity, launch_rate, damage):
106            self.capacity = capacity
107            self.launch_rate = launch_rate
108            self.damage = damage
109
110        def __str__(self):
111            return str(self.get_stats())
112
113        def get_stats(self):
114            """
115            :return: capacity, launch_rate, damage
116            """
117            return self.capacity, self.launch_rate, self.damage
118
119    class AntiFighterStats:
120        def __init__(self, flak_shots: int, has_interceptors: bool):
121            """
122            :param flak_shots: number of shots per bout with flak weapon part
123            :param has_interceptors: true if mounted hangar parts have interceptor ability (interceptors/fighters)
124            """
125            self.flak_shots = flak_shots
126            self.has_interceptors = has_interceptors
127
128        def __str__(self):
129            return str(self.get_stats())
130
131        def get_stats(self):
132            """
133            :return: flak_shots, has_interceptors
134            """
135            return self.flak_shots, self.has_interceptors
136
137    class AntiPlanetStats:
138        def __init__(self, damage_vs_planets, has_bomber):
139            self.damage_vs_planets = damage_vs_planets
140            self.has_bomber = has_bomber
141
142        def get_stats(self):
143            return self.damage_vs_planets, self.has_bomber
144
145        def __str__(self):
146            return str(self.get_stats())
147
148    def __init__(self, ship_id=INVALID_ID, consider_refuel=False, stats=None):
149        self.__ship_id = ship_id
150        self._consider_refuel = consider_refuel
151        if stats:
152            self._basic_stats = self.BasicStats(*stats[0:3])  # TODO: Should probably determine size dynamically
153            self._fighter_stats = self.FighterStats(*stats[3:6])
154            self._anti_fighter_stats = self.AntiFighterStats(*stats[6:8])
155            self._anti_planet_stats = self.AntiPlanetStats(*stats[8:])
156        else:
157            self._basic_stats = self.BasicStats(None, None, None)
158            self._fighter_stats = self.FighterStats(None, None, None)
159            self._anti_fighter_stats = self.AntiFighterStats(0, False)
160            self._anti_planet_stats = self.AntiPlanetStats(0, False)
161            self.__get_stats_from_ship()
162
163    def __hash__(self):
164        return hash(self.get_basic_stats(hashable=True))
165
166    def __str__(self):
167        return str(self.get_stats())
168
169    def __get_stats_from_ship(self):
170        """Read and store combat related stats from ship"""
171        universe = fo.getUniverse()
172        ship = universe.getShip(self.__ship_id)
173        if not ship:
174            return  # TODO: Add some estimate for stealthed ships
175
176        if self._consider_refuel:
177            structure = ship.initialMeterValue(fo.meterType.maxStructure)
178            shields = ship.initialMeterValue(fo.meterType.maxShield)
179        else:
180            structure = ship.initialMeterValue(fo.meterType.structure)
181            shields = ship.initialMeterValue(fo.meterType.shield)
182        attacks = {}
183        fighter_launch_rate = 0
184        fighter_capacity = 0
185        fighter_damage = 0
186        flak_shots = 0
187        has_bomber = False
188        has_interceptors = False
189        damage_vs_planets = 0
190        design = ship.design
191        if design and (ship.isArmed or ship.hasFighters):
192            meter_choice = fo.meterType.maxCapacity if self._consider_refuel else fo.meterType.capacity
193            for partname in design.parts:
194                if not partname:
195                    continue
196                pc = get_ship_part(partname).partClass
197                if pc == fo.shipPartClass.shortRange:
198                    allowed_targets = get_allowed_targets(partname)
199                    damage = ship.currentPartMeterValue(meter_choice, partname)
200                    shots = ship.currentPartMeterValue(fo.meterType.secondaryStat, partname)
201                    if allowed_targets & CombatTarget.SHIP:
202                        attacks[damage] = attacks.get(damage, 0) + shots
203                    if allowed_targets & CombatTarget.FIGHTER:
204                        flak_shots += 1
205                    if allowed_targets & CombatTarget.PLANET:
206                        damage_vs_planets += damage * shots
207                elif pc == fo.shipPartClass.fighterBay:
208                    fighter_launch_rate += ship.currentPartMeterValue(fo.meterType.capacity, partname)
209                elif pc == fo.shipPartClass.fighterHangar:
210                    allowed_targets = get_allowed_targets(partname)
211                    # for hangars, capacity meter is already counting contributions from ALL hangars.
212                    fighter_capacity = ship.currentPartMeterValue(meter_choice, partname)
213                    part_damage = ship.currentPartMeterValue(fo.meterType.secondaryStat, partname)
214                    if part_damage != fighter_damage and fighter_damage > 0:
215                        # the C++ code fails also in this regard, so FOCS content *should* not allow this.
216                        # TODO: Depending on future implementation, might actually need to handle this case.
217                        warning("Multiple hangar types present on one ship, estimates expected to be wrong.")
218                    if allowed_targets & CombatTarget.SHIP:
219                        fighter_damage = max(fighter_damage, part_damage)
220                    if allowed_targets & CombatTarget.PLANET:
221                        has_bomber = True
222                    if allowed_targets & CombatTarget.FIGHTER:
223                        has_interceptors = True
224
225        self._basic_stats = self.BasicStats(attacks, structure, shields)
226        self._fighter_stats = self.FighterStats(fighter_capacity, fighter_launch_rate, fighter_damage)
227        self._anti_fighter_stats = self.AntiFighterStats(flak_shots, has_interceptors)
228        self._anti_planet_stats = self.AntiPlanetStats(damage_vs_planets, has_bomber)
229
230    def get_basic_stats(self, hashable=False):
231        """Get non-fighter-related combat stats of the ship.
232
233        :param hashable: if true, returns tuple instead of attacks-dict
234        :return: attacks, structure, shields
235        :rtype: (dict|tuple, float, float)
236        """
237        return self._basic_stats.get_stats(hashable=hashable)
238
239    def get_fighter_stats(self):
240        """ Get fighter related combat stats
241        :return: capacity, launch_rate, damage
242        """
243        return self._fighter_stats.get_stats()
244
245    def get_anti_fighter_stats(self):
246        """Get anti-fighter related stats
247        :return: flak_shots, has_interceptors
248        """
249        return self._anti_fighter_stats.get_stats()
250
251    def get_anti_planet_stats(self):
252        return self._anti_planet_stats.get_stats()
253
254    def get_rating(self, enemy_stats=None, ignore_fighters=False):
255        """Calculate a rating against specified enemy.
256
257        If no enemy is specified, will rate against the empire standard enemy
258
259        :param enemy_stats: Enemy stats to be rated against - if None
260        :type enemy_stats: ShipCombatStats
261        :param ignore_fighters: If True, acts as if fighters are not launched
262        :type ignore_fighters: bool
263        :return: rating against specified enemy
264        :rtype: float
265        """
266        # adjust base stats according to enemy stats
267        def _rating():
268            return my_total_attack * my_structure
269
270        # The fighter rating calculations are heavily based upon the enemy stats.
271        # So, for now, we compare at least against a certain standard enemy.
272        enemy_stats = enemy_stats or get_aistate().get_standard_enemy()
273
274        my_attacks, my_structure, my_shields = self.get_basic_stats()
275        # e_avg_attack = 1
276        if enemy_stats:
277            e_attacks, e_structure, e_shields = enemy_stats.get_basic_stats()
278            if e_attacks:
279                # e_num_attacks = sum(n for n in e_attacks.values())
280                e_total_attack = sum(n*dmg for dmg, n in e_attacks.items())
281                # e_avg_attack = e_total_attack / e_num_attacks
282                e_net_attack = sum(n*max(dmg - my_shields, .001) for dmg, n in e_attacks.items())
283                e_net_attack = max(e_net_attack, .1*e_total_attack)
284                shield_factor = e_total_attack / e_net_attack
285                my_structure *= max(1, shield_factor)
286            my_total_attack = sum(n*max(dmg - e_shields, .001) for dmg, n in my_attacks.items())
287        else:
288            my_total_attack = sum(n*dmg for dmg, n in my_attacks.items())
289            my_structure += my_shields
290
291        if ignore_fighters:
292            return _rating()
293
294        my_total_attack += self.estimate_fighter_damage()
295
296        # TODO: Consider enemy fighters
297
298        return _rating()
299
300    def estimate_fighter_damage(self):
301        capacity, launch_rate, fighter_damage = self.get_fighter_stats()
302        if launch_rate == 0:
303            return 0
304        full_launch_bouts = capacity // launch_rate
305        survival_rate = 0.2  # TODO estimate chance of a fighter not to be shot down in a bout
306        flying_fighters = 0
307        total_fighter_damage = 0
308        # Cut that values down to a single turn (four bouts means max three launch bouts)
309        num_bouts = fo.getGameRules().getInt("RULE_NUM_COMBAT_ROUNDS")
310        for firing_bout in range(num_bouts - 1):
311            if firing_bout < full_launch_bouts:
312                flying_fighters = (flying_fighters * survival_rate) + launch_rate
313            elif firing_bout == full_launch_bouts:
314                # now handle a bout with lower capacity launch
315                flying_fighters = (flying_fighters * survival_rate) + (capacity % launch_rate)
316            else:
317                flying_fighters = (flying_fighters * survival_rate)
318            total_fighter_damage += fighter_damage * flying_fighters
319        return total_fighter_damage / num_bouts
320
321    def get_rating_vs_planets(self):
322        """Heuristic to estimate combat strength against planets"""
323        damage = self._anti_planet_stats.damage_vs_planets
324        if self._anti_planet_stats.has_bomber:
325            damage += self.estimate_fighter_damage()
326        return damage * (self._basic_stats.structure + self._basic_stats.shields)
327
328    def get_stats(self, hashable=False):
329        """ Get all combat related stats of the ship.
330
331        :param hashable: if true, return tuple instead of dict for attacks
332        :return: attacks, structure, shields, fighter-capacity, fighter-launch_rate, fighter-damage
333        """
334        return (self.get_basic_stats(hashable=hashable) + self.get_fighter_stats()
335                + self.get_anti_fighter_stats() + self.get_anti_planet_stats())
336
337
338class FleetCombatStats:
339    """Stores combat related stats of the fleet."""
340    def __init__(self, fleet_id=INVALID_ID, consider_refuel=False):
341        self.__fleet_id = fleet_id
342        self._consider_refuel = consider_refuel
343        self.__ship_stats = []
344        self.__get_stats_from_fleet()
345
346    def get_ship_stats(self, hashable=False):
347        """Get combat stats of all ships in fleet.
348
349        :param hashable: if true, returns tuple instead of dict for attacks
350        :type hashable: bool
351        :return: list of ship stats
352        :rtype: list
353        """
354        return map(lambda x: x.get_stats(hashable=hashable), self.__ship_stats)
355
356    def get_ship_combat_stats(self):
357        """Returns list of ShipCombatStats of fleet."""
358        return list(self.__ship_stats)
359
360    def get_rating(self, enemy_stats=None, ignore_fighters=False):
361        """Calculates the rating of the fleet by combining all its ships ratings.
362
363        :param enemy_stats: enemy to be rated against
364        :type enemy_stats: ShipCombatStats
365        :param ignore_fighters: If True, acts as if fighters are not launched
366        :type ignore_fighters: bool
367        :return: Rating of the fleet
368        :rtype: float
369        """
370        return combine_ratings_list([x.get_rating(enemy_stats, ignore_fighters) for x in self.__ship_stats])
371
372    def get_rating_vs_planets(self):
373        return combine_ratings_list([x.get_rating_vs_planets() for x in self.__ship_stats])
374
375    def __get_stats_from_fleet(self):
376        """Calculate fleet combat stats (i.e. the stats of all its ships)."""
377        universe = fo.getUniverse()
378        fleet = universe.getFleet(self.__fleet_id)
379        if not fleet:
380            return
381        for ship_id in fleet.shipIDs:
382            self.__ship_stats.append(ShipCombatStats(ship_id, self._consider_refuel))
383
384
385def get_fleet_rating(fleet_id, enemy_stats=None):
386    """Get rating for the fleet against specified enemy.
387
388    :param fleet_id: fleet to be rated
389    :type fleet_id: int
390    :param enemy_stats: enemy to be rated against
391    :type enemy_stats: ShipCombatStats
392    :return: Rating
393    :rtype: float
394    """
395    return FleetCombatStats(fleet_id, consider_refuel=False).get_rating(enemy_stats)
396
397
398def get_fleet_rating_against_planets(fleet_id):
399    return FleetCombatStats(fleet_id, consider_refuel=False).get_rating_vs_planets()
400
401
402def get_ship_rating(ship_id, enemy_stats=None):
403    return ShipCombatStats(ship_id, consider_refuel=False).get_rating(enemy_stats)
404
405
406def weight_attack_troops(troops, grade):
407    """Re-weights troops on a ship based on species piloting grade.
408
409    :type troops: float
410    :type grade: str
411    :return: piloting grade weighted troops
412    :rtype: float
413    """
414    weight = {'NO': 0.0, 'BAD': 0.5, '': 1.0, 'GOOD': 1.5, 'GREAT': 2.0, 'ULTIMATE': 3.0}.get(grade, 1.0)
415    return troops * weight
416
417
418def weight_shields(shields, grade):
419    """Re-weights shields based on species defense bonus."""
420    offset = {'NO': 0, 'BAD': 0, '': 0, 'GOOD': 1.0, 'GREAT': 0, 'ULTIMATE': 0}.get(grade, 0)
421    return shields + offset
422
423
424def combine_ratings(rating1, rating2):
425    """ Combines two combat ratings to a total rating.
426
427    The formula takes into account the fact that the combined strength of two ships is more than the
428    sum of its individual ratings. Basic idea as follows:
429
430    We use the following definitions
431
432    r: rating
433    a: attack
434    s: structure
435
436    where we take into account effective values after accounting for e.g. shields effects.
437
438    We generally define the rating of a ship as
439    r_i = a_i*s_i                                                                   (1)
440
441    A natural extension for the combined rating of two ships is
442    r_tot = (a_1+a_2)*(s_1+s_2)                                                     (2)
443
444    Assuming         a_i approx s_i                                                 (3)
445    It follows that  a_i approx sqrt(r_i) approx s_i                                (4)
446    And thus         r_tot = (sqrt(r_1)+sqrt(r_2))^2 = r1 + r2 + 2*sqrt(r1*r2)      (5)
447
448    Note that this function has commutative and associative properties.
449
450    :param rating1:
451    :type rating1: float
452    :param rating2:
453    :type rating2: float
454    :return: combined rating
455    :rtype: float
456    """
457    return rating1 + rating2 + 2 * (rating1 * rating2)**0.5
458
459
460def combine_ratings_list(ratings_list):
461    """ Combine ratings in the list.
462
463    Repetitively calls combine_ratings() until finished.
464
465    :param ratings_list: list of ratings to be combined
466    :type ratings_list: list
467    :return: combined rating
468    :rtype: float
469    """
470    return reduce(combine_ratings, ratings_list, 0)
471
472
473def rating_needed(target, current=0):
474    """Estimate the needed rating to achieve target rating.
475
476    :param target: Target rating to be reached
477    :type target: float
478    :param current: Already existing rating
479    :type current: float
480    :return: Estimated missing rating to reach target
481    :rtype: float
482    """
483    if current >= target or target <= 0:
484        return 0
485    else:
486        return target + current - 2 * (target * current)**0.5
487
488
489def rating_difference(first_rating, second_rating):
490
491    """Return the absolute nonlinear difference between ratings.
492
493    :param first_rating: rating of a first force
494    :type first_rating: float
495    :param second_rating: rating of a second force
496    :type second_rating: float
497    :return: Estimated rating by which the greater force (nonlinearly) exceeds the lesser
498    :rtype: float
499    """
500
501    return rating_needed(max(first_rating, second_rating), min(first_rating, second_rating))
502