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