1from logging import debug, warning
2
3import freeOrionAIInterface as fo  # pylint: disable=import-error
4
5# the following import is used for type hinting, which pylint seems not to recognize
6from fleet_orders import AIFleetOrder  # pylint: disable=unused-import # noqa: F401
7from fleet_orders import OrderMove, OrderPause, OrderOutpost, OrderColonize, OrderMilitary, OrderInvade, OrderDefend
8import AIstate
9import EspionageAI
10import FleetUtilsAI
11import MoveUtilsAI
12import MilitaryAI
13import InvasionAI
14import CombatRatingsAI
15from aistate_interface import get_aistate
16from target import TargetSystem, TargetFleet, TargetPlanet
17from EnumsAI import MissionType
18from AIDependencies import INVALID_ID
19from freeorion_tools import get_partial_visibility_turn, assertion_fails
20
21ORDERS_FOR_MISSION = {
22    MissionType.EXPLORATION: OrderPause,
23    MissionType.OUTPOST: OrderOutpost,
24    MissionType.COLONISATION: OrderColonize,
25    MissionType.INVASION: OrderInvade,
26    MissionType.MILITARY: OrderMilitary,
27    # SECURE is mostly same as MILITARY, but waits for system removal from all targeted system lists
28    # (invasion, colonization, outpost, blockade) before clearing
29    MissionType.SECURE: OrderMilitary,
30    MissionType.PROTECT_REGION: OrderPause,
31    MissionType.ORBITAL_INVASION: OrderInvade,
32    MissionType.ORBITAL_OUTPOST: OrderOutpost,
33    MissionType.ORBITAL_DEFENSE: OrderDefend,
34}
35
36COMBAT_MISSION_TYPES = (
37    MissionType.MILITARY,
38    MissionType.SECURE,
39    MissionType.PROTECT_REGION,
40)
41
42MERGEABLE_MISSION_TYPES = (
43    MissionType.MILITARY,
44    MissionType.INVASION,
45    MissionType.PROTECT_REGION,
46    MissionType.ORBITAL_INVASION,
47    MissionType.SECURE,
48    MissionType.ORBITAL_DEFENSE,
49)
50
51
52class AIFleetMission:
53    """
54    Stores information about AI mission. Every mission has fleetID and AI targets depending upon AI fleet mission type.
55    :type orders: list[AIFleetOrder]
56    :type target: target.Target | None
57    """
58
59    def __init__(self, fleet_id):
60        self.orders = []
61        self.fleet = TargetFleet(fleet_id)
62        self.type = None
63        self.target = None
64
65    def __setstate__(self, state):
66        target_type = state.pop("target_type")
67        if state["target"] is not None:
68            object_map = {TargetPlanet.object_name: TargetPlanet,
69                          TargetSystem.object_name: TargetSystem,
70                          TargetFleet.object_name: TargetFleet}
71            state["target"] = object_map[target_type](state["target"])
72
73        state["fleet"] = TargetFleet(state["fleet"])
74        self.__dict__ = state
75
76    def __getstate__(self):
77        retval = dict(self.__dict__)
78        # do only store the fleet id not the Fleet object
79        retval["fleet"] = self.fleet.id
80
81        # store target type and id rather than the object
82        if self.target is not None:
83            retval["target_type"] = self.target.object_name
84            retval["target"] = self.target.id
85        else:
86            retval["target_type"] = None
87            retval["target"] = None
88
89        return retval
90
91    def set_target(self, mission_type, target):
92        """
93        Set mission and target for this fleet.
94
95        :type mission_type: MissionType
96        :type target: target.Target
97        """
98        if self.type == mission_type and self.target == target:
99            return
100        if self.type or self.target:
101            debug("%s: change mission assignment from %s:%s to %s:%s" % (
102                self.fleet, self.type, self.target, mission_type, target))
103        self.type = mission_type
104        self.target = target
105
106    def clear_target(self):
107        """Clear target and mission for this fleet."""
108        self.target = None
109        self.type = None
110
111    def has_target(self, mission_type, target):
112        """
113        Check if fleet has specified mission_type and target.
114
115        :type mission_type: MissionType
116        :type target: target.Target
117        :rtype: bool
118        """
119        return self.type == mission_type and self.target == target
120
121    def clear_fleet_orders(self):
122        """Clear this fleets orders but do not clear mission and target."""
123        self.orders = []
124
125    def _get_fleet_order_from_target(self, mission_type, target):
126        """
127        Get a fleet order according to mission type and target.
128
129        :type mission_type: MissionType
130        :type target: target.Target
131        :rtype: AIFleetOrder
132        """
133        fleet_target = TargetFleet(self.fleet.id)
134        return ORDERS_FOR_MISSION[mission_type](fleet_target, target)
135
136    def check_mergers(self, context=""):
137        """
138        Merge local fleets with same mission into this fleet.
139
140        :param context: Context of the function call for logging purposes
141        :type context: str
142        """
143        debug("Considering to merge %s", self.__str__())
144        if self.type not in MERGEABLE_MISSION_TYPES:
145            debug("Mission type does not allow merging.")
146            return
147
148        if not self.target:
149            debug("Mission has no valid target - do not merge.")
150            return
151
152        universe = fo.getUniverse()
153        empire_id = fo.empireID()
154
155        fleet_id = self.fleet.id
156        main_fleet = universe.getFleet(fleet_id)
157        main_fleet_system_id = main_fleet.systemID
158        if main_fleet_system_id == INVALID_ID:
159            debug("Can't merge: fleet in middle of starlane.")
160            return
161
162        # only merge PROTECT_REGION if there is any threat near target
163        if self.type == MissionType.PROTECT_REGION:
164            neighbor_systems = universe.getImmediateNeighbors(self.target.id, empire_id)
165            if not any(MilitaryAI.get_system_local_threat(sys_id)
166                       for sys_id in neighbor_systems):
167                debug("Not merging PROTECT_REGION fleet - no threat nearby.")
168                return
169
170        destroyed_list = set(universe.destroyedObjectIDs(empire_id))
171        aistate = get_aistate()
172        system_status = aistate.systemStatus[main_fleet_system_id]
173        other_fleets_here = [fid for fid in system_status.get('myFleetsAccessible', []) if fid != fleet_id and
174                             fid not in destroyed_list and universe.getFleet(fid).ownedBy(empire_id)]
175        if not other_fleets_here:
176            debug("No other fleets here")
177            return
178
179        for fid in other_fleets_here:
180            fleet_mission = aistate.get_fleet_mission(fid)
181            if fleet_mission.type != self.type or fleet_mission.target != self.target:
182                debug("Local candidate %s does not have same mission." % fleet_mission)
183                continue
184            FleetUtilsAI.merge_fleet_a_into_b(fid, fleet_id, context="Order %s of mission %s" % (context, self))
185
186    def _is_valid_fleet_mission_target(self, mission_type, target):
187        if not target:
188            return False
189        if mission_type == MissionType.EXPLORATION:
190            if isinstance(target, TargetSystem):
191                empire = fo.getEmpire()
192                if not empire.hasExploredSystem(target.id):
193                    return True
194        elif mission_type in [MissionType.OUTPOST, MissionType.ORBITAL_OUTPOST]:
195            fleet = self.fleet.get_object()
196            if not fleet.hasOutpostShips:
197                return False
198            if isinstance(target, TargetPlanet):
199                planet = target.get_object()
200                if planet.unowned:
201                    return True
202        elif mission_type == MissionType.COLONISATION:
203            fleet = self.fleet.get_object()
204            if not fleet.hasColonyShips:
205                return False
206            if isinstance(target, TargetPlanet):
207                planet = target.get_object()
208                population = planet.initialMeterValue(fo.meterType.population)
209                if planet.unowned or (planet.owner == fleet.owner and population == 0):
210                    return True
211        elif mission_type in [MissionType.INVASION, MissionType.ORBITAL_INVASION]:
212            fleet = self.fleet.get_object()
213            if not fleet.hasTroopShips:
214                return False
215            if isinstance(target, TargetPlanet):
216                planet = target.get_object()
217                # TODO remove latter portion of next check in light of invasion retargeting, or else correct logic
218                if not planet.unowned or planet.owner != fleet.owner:
219                    return True
220        elif mission_type in [MissionType.MILITARY, MissionType.SECURE,
221                              MissionType.ORBITAL_DEFENSE, MissionType.PROTECT_REGION]:
222            if isinstance(target, TargetSystem):
223                return True
224        # TODO: implement other mission types
225        return False
226
227    def clean_invalid_targets(self):
228        """clean invalid AITargets"""
229        if not self._is_valid_fleet_mission_target(self.type, self.target):
230            self.target = None
231            self.type = None
232
233    def _check_abort_mission(self, fleet_order):
234        """ checks if current mission (targeting a planet) should be aborted"""
235        planet_stealthed = False
236        target_is_planet = fleet_order.target and isinstance(fleet_order.target, TargetPlanet)
237        planet = None
238        if target_is_planet:
239            planet = fleet_order.target.get_object()
240            # Check visibility prediction, but if somehow still have current visibility, don't
241            # abort the mission yet
242            if not EspionageAI.colony_detectable_by_empire(planet.id, empire=fo.empireID()):
243                if get_partial_visibility_turn(planet.id) == fo.currentTurn():
244                    debug("EspionageAI predicts planet id %d to be stealthed" % planet.id +
245                          ", but somehow have current visibity anyway, so won't trigger mission abort")
246                else:
247                    debug("EspionageAI predicts we can no longer detect %s, will abort mission" % fleet_order.target)
248                    planet_stealthed = True
249        if target_is_planet and not planet_stealthed:
250            if isinstance(fleet_order, OrderColonize):
251                if (planet.initialMeterValue(fo.meterType.population) == 0 and
252                        (planet.ownedBy(fo.empireID()) or planet.unowned)):
253                    return False
254            elif isinstance(fleet_order, OrderOutpost):
255                if planet.unowned:
256                    return False
257            elif isinstance(fleet_order, OrderInvade):  # TODO add substantive abort check
258                return False
259            else:
260                return False
261
262        # canceling fleet orders
263        debug("   %s" % fleet_order)
264        debug("Fleet %d had a target planet that is no longer valid for this mission; aborting." % self.fleet.id)
265        self.clear_fleet_orders()
266        self.clear_target()
267        FleetUtilsAI.split_fleet(self.fleet.id)
268        return True
269
270    def _check_retarget_invasion(self):
271        """checks if an invasion mission should be retargeted"""
272        universe = fo.getUniverse()
273        empire_id = fo.empireID()
274        fleet_id = self.fleet.id
275        fleet = universe.getFleet(fleet_id)
276        if fleet.systemID == INVALID_ID:
277            # next_loc = fleet.nextSystemID
278            return  # TODO: still check
279        system = universe.getSystem(fleet.systemID)
280        if not system:
281            return
282        orders = self.orders
283        last_sys_target = INVALID_ID
284        if orders:
285            last_sys_target = orders[-1].target.id
286        if last_sys_target == fleet.systemID:
287            return  # TODO: check for best local target
288        open_targets = []
289        already_targeted = InvasionAI.get_invasion_targeted_planet_ids(system.planetIDs, MissionType.INVASION)
290        aistate = get_aistate()
291        for pid in system.planetIDs:
292            if pid in already_targeted or (pid in aistate.qualifyingTroopBaseTargets):
293                continue
294            planet = universe.getPlanet(pid)
295            if planet.unowned or (planet.owner == empire_id):
296                continue
297            if (planet.initialMeterValue(fo.meterType.shield)) <= 0:
298                open_targets.append(pid)
299        if not open_targets:
300            return
301        troops_in_fleet = FleetUtilsAI.count_troops_in_fleet(fleet_id)
302        target_id = INVALID_ID
303        best_score = -1
304        target_troops = 0
305        #
306        for pid, rating in InvasionAI.assign_invasion_values(open_targets).items():
307            p_score, p_troops = rating
308            if p_score > best_score:
309                if p_troops >= troops_in_fleet:
310                    continue
311                best_score = p_score
312                target_id = pid
313                target_troops = p_troops
314        if target_id == INVALID_ID:
315            return
316
317        debug("\t Splitting and retargetting fleet %d" % fleet_id)
318        new_fleets = FleetUtilsAI.split_fleet(fleet_id)
319        self.clear_target()  # TODO: clear from foAIstate
320        self.clear_fleet_orders()
321        troops_needed = max(0, target_troops - FleetUtilsAI.count_troops_in_fleet(fleet_id))
322        min_stats = {'rating': 0, 'troopCapacity': troops_needed}
323        target_stats = {'rating': 10, 'troopCapacity': troops_needed}
324        found_fleets = []
325        # TODO check if next statement does not mutate any global states and can be removed
326
327        _ = FleetUtilsAI.get_fleets_for_mission(target_stats, min_stats, {}, starting_system=fleet.systemID,  # noqa: F841
328                                                fleet_pool_set=set(new_fleets), fleet_list=found_fleets)
329        for fid in found_fleets:
330            FleetUtilsAI.merge_fleet_a_into_b(fid, fleet_id)
331        target = TargetPlanet(target_id)
332        self.set_target(MissionType.INVASION, target)
333        self.generate_fleet_orders()
334
335    def need_to_pause_movement(self, last_move_target_id, new_move_order):
336        """
337        When a fleet has consecutive move orders, assesses whether something about the interim destination warrants
338        forcing a stop (such as a military fleet choosing to engage with an enemy fleet about to enter the same system,
339        or it may provide a good vantage point to view current status of next system in path). Assessments about whether
340        the new destination is suitable to move to are (currently) separately made by OrderMove.can_issue_order()
341        :param last_move_target_id:
342        :type last_move_target_id: int
343        :param new_move_order:
344        :type new_move_order: OrderMove
345        :rtype: bool
346        """
347        fleet = self.fleet.get_object()
348        # don't try skipping over more than one System
349        if fleet.nextSystemID != last_move_target_id:
350            return True
351        universe = fo.getUniverse()
352        current_dest_system = universe.getSystem(fleet.nextSystemID)
353        if not current_dest_system:
354            # shouldn't really happen, but just to be safe
355            return True
356        distance_to_next_system = ((fleet.x - current_dest_system.x)**2 + (fleet.y - current_dest_system.y)**2)**0.5
357        surplus_travel_distance = fleet.speed - distance_to_next_system
358        # if need more than one turn to reach current destination, then don't add another jump yet
359        if surplus_travel_distance < 0:
360            return True
361        # TODO: add assessments for other situations we'd prefer to pause, such as cited above re military fleets, and
362        # for situations where high value fleets like colony fleets might deem it safest to stop and look around
363        # before proceeding
364        return False
365
366    def issue_fleet_orders(self):
367        """issues AIFleetOrders which can be issued in system and moves to next one if is possible"""
368        # TODO: priority
369        order_completed = True
370
371        debug("\nChecking orders for fleet %s (on turn %d), with mission type %s and target %s",
372              self.fleet.get_object(), fo.currentTurn(), self.type or 'No mission', self.target or 'No Target')
373        if MissionType.INVASION == self.type:
374            self._check_retarget_invasion()
375        just_issued_move_order = False
376        last_move_target_id = INVALID_ID
377        # Note: the following abort check somewhat assumes only one major mission type
378        for fleet_order in self.orders:
379            if (isinstance(fleet_order, (OrderColonize, OrderOutpost, OrderInvade)) and
380                    self._check_abort_mission(fleet_order)):
381                return
382        aistate = get_aistate()
383        for fleet_order in self.orders:
384            if just_issued_move_order and self.fleet.get_object().systemID != last_move_target_id:
385                # having just issued a move order, we will normally stop issuing orders this turn, except that if there
386                # are consecutive move orders we will consider moving through the first destination rather than stopping
387                # Without the below noinspection directive, PyCharm is concerned about the 2nd part of the test
388                # noinspection PyTypeChecker
389                if (not isinstance(fleet_order, OrderMove) or
390                        self.need_to_pause_movement(last_move_target_id, fleet_order)):
391                    break
392            debug("Checking order: %s" % fleet_order)
393            self.check_mergers(context=str(fleet_order))
394            if fleet_order.can_issue_order(verbose=False):
395                # only move if all other orders completed
396                if isinstance(fleet_order, OrderMove) and order_completed:
397                    debug("Issuing fleet order %s" % fleet_order)
398                    fleet_order.issue_order()
399                    just_issued_move_order = True
400                    last_move_target_id = fleet_order.target.id
401                elif not isinstance(fleet_order, OrderMove):
402                    debug("Issuing fleet order %s" % fleet_order)
403                    fleet_order.issue_order()
404                else:
405                    debug("NOT issuing (even though can_issue) fleet order %s" % fleet_order)
406                status_words = tuple(["not", ""][_s] for _s in [fleet_order.order_issued, fleet_order.executed])
407                debug("Order %s issued and %s fully executed." % status_words)
408                if not fleet_order.executed:
409                    order_completed = False
410            else:  # check that we're not held up by a Big Monster
411                if fleet_order.order_issued:
412                    # A previously issued order that wasn't instantly executed must have had cirumstances change so that
413                    # the order can't currently be reissued (or perhaps simply a savegame has been reloaded on the same
414                    # turn the order was issued).
415                    if not fleet_order.executed:
416                        order_completed = False
417                    # Go on to the next order.
418                    continue
419                debug("CAN'T issue fleet order %s because:" % fleet_order)
420                fleet_order.can_issue_order(verbose=True)
421                if isinstance(fleet_order, OrderMove):
422                    this_system_id = fleet_order.target.id
423                    this_status = aistate.systemStatus.setdefault(this_system_id, {})
424                    threat_threshold = fo.currentTurn() * MilitaryAI.cur_best_mil_ship_rating() / 4.0
425                    if this_status.get('monsterThreat', 0) > threat_threshold:
426                        # if this move order is not this mil fleet's final destination, and blocked by Big Monster,
427                        # release and hope for more effective reassignment
428                        if (self.type not in (MissionType.MILITARY, MissionType.SECURE) or
429                                fleet_order != self.orders[-1]):
430                            debug("Aborting mission due to being blocked by Big Monster at system %d, threat %d" % (
431                                this_system_id, aistate.systemStatus[this_system_id]['monsterThreat']))
432                            debug("Full set of orders were:")
433                            for this_order in self.orders:
434                                debug(" - %s" % this_order)
435                            self.clear_fleet_orders()
436                            self.clear_target()
437                            return
438                break  # do not order the next order until this one is finished.
439        else:  # went through entire order list
440            if order_completed:
441                debug("Final order is completed")
442                orders = self.orders
443                last_order = orders[-1] if orders else None
444                universe = fo.getUniverse()
445
446                if last_order and isinstance(last_order, OrderColonize):
447                    planet = universe.getPlanet(last_order.target.id)
448                    sys_partial_vis_turn = get_partial_visibility_turn(planet.systemID)
449                    planet_partial_vis_turn = get_partial_visibility_turn(planet.id)
450                    if (planet_partial_vis_turn == sys_partial_vis_turn and
451                            not planet.initialMeterValue(fo.meterType.population)):
452                        warning("Fleet %s has tentatively completed its "
453                                "colonize mission but will wait to confirm population.", self.fleet)
454                        debug("    Order details are %s" % last_order)
455                        debug("    Order is valid: %s; issued: %s; executed: %s" % (
456                            last_order.is_valid(), last_order.order_issued, last_order.executed))
457                        if not last_order.is_valid():
458                            source_target = last_order.fleet
459                            target_target = last_order.target
460                            debug("        source target validity: %s; target target validity: %s " % (
461                                bool(source_target), bool(target_target)))
462                        return  # colonize order must not have completed yet
463                clear_all = True
464                last_sys_target = INVALID_ID
465                if last_order and isinstance(last_order, OrderMilitary):
466                    last_sys_target = last_order.target.id
467                    # not doing this until decide a way to release from a SECURE mission
468                    # if (MissionType.SECURE == self.type) or
469                    secure_targets = set(AIstate.colonyTargetedSystemIDs +
470                                         AIstate.outpostTargetedSystemIDs +
471                                         AIstate.invasionTargetedSystemIDs)
472                    if last_sys_target in secure_targets:  # consider a secure mission
473                        if last_sys_target in AIstate.colonyTargetedSystemIDs:
474                            secure_type = "Colony"
475                        elif last_sys_target in AIstate.outpostTargetedSystemIDs:
476                            secure_type = "Outpost"
477                        elif last_sys_target in AIstate.invasionTargetedSystemIDs:
478                            secure_type = "Invasion"
479                        else:
480                            secure_type = "Unidentified"
481                        debug("Fleet %d has completed initial stage of its mission "
482                              "to secure system %d (targeted for %s), "
483                              "may release a portion of ships" % (self.fleet.id, last_sys_target, secure_type))
484                        clear_all = False
485
486                # for PROTECT_REGION missions, only release fleet if no more threat
487                if self.type == MissionType.PROTECT_REGION:
488                    # use military logic code below to determine if can release
489                    # any or even all of the ships.
490                    clear_all = False
491                    last_sys_target = self.target.id
492                    debug("Check if PROTECT_REGION mission with target %d is finished.", last_sys_target)
493
494                fleet_id = self.fleet.id
495                if clear_all:
496                    if orders:
497                        debug("Fleet %d has completed its mission; clearing all orders and targets." % self.fleet.id)
498                        debug("Full set of orders were:")
499                        for this_order in orders:
500                            debug("\t\t %s" % this_order)
501                        self.clear_fleet_orders()
502                        self.clear_target()
503                        if aistate.get_fleet_role(fleet_id) in (MissionType.MILITARY, MissionType.SECURE):
504                            allocations = MilitaryAI.get_military_fleets(mil_fleets_ids=[fleet_id],
505                                                                         try_reset=False,
506                                                                         thisround="Fleet %d Reassignment" % fleet_id)
507                            if allocations:
508                                MilitaryAI.assign_military_fleets_to_systems(use_fleet_id_list=[fleet_id],
509                                                                             allocations=allocations)
510                    else:  # no orders
511                        debug("No Current Orders")
512                else:
513                    potential_threat = CombatRatingsAI.combine_ratings(
514                        MilitaryAI.get_system_local_threat(last_sys_target),
515                        MilitaryAI.get_system_neighbor_threat(last_sys_target)
516                    )
517                    threat_present = potential_threat > 0
518                    debug("Fleet threat present? %s", threat_present)
519                    target_system = universe.getSystem(last_sys_target)
520                    if not threat_present and target_system:
521                        for pid in target_system.planetIDs:
522                            planet = universe.getPlanet(pid)
523                            if (planet and
524                                    planet.owner != fo.empireID() and
525                                    planet.currentMeterValue(fo.meterType.maxDefense) > 0):
526                                debug("Found local planetary threat: %s", planet)
527                                threat_present = True
528                                break
529                    if not threat_present:
530                        debug("No current threat in target system; releasing a portion of ships.")
531                        # at least first stage of current task is done;
532                        # release extra ships for potential other deployments
533                        new_fleets = FleetUtilsAI.split_fleet(self.fleet.id)
534                        if self.type == MissionType.PROTECT_REGION:
535                            self.clear_fleet_orders()
536                            self.clear_target()
537                            new_fleets.append(self.fleet.id)
538                    else:
539                        debug("Threat remains in target system; Considering to release some ships.")
540                        new_fleets = []
541                        fleet_portion_to_remain = self._portion_of_fleet_needed_here()
542                        if fleet_portion_to_remain >= 1:
543                            debug("Can not release fleet yet due to large threat.")
544                        elif fleet_portion_to_remain > 0:
545                            debug("Not all ships are needed here - considering releasing a few")
546                            # TODO: Rate against specific enemy threat cause
547                            fleet_remaining_rating = CombatRatingsAI.get_fleet_rating(fleet_id)
548                            fleet_min_rating = fleet_portion_to_remain * fleet_remaining_rating
549                            debug("Starting rating: %.1f, Target rating: %.1f",
550                                  fleet_remaining_rating, fleet_min_rating)
551                            allowance = CombatRatingsAI.rating_needed(fleet_remaining_rating, fleet_min_rating)
552                            debug("May release ships with total rating of %.1f", allowance)
553                            ship_ids = list(self.fleet.get_object().shipIDs)
554                            for ship_id in ship_ids:
555                                ship_rating = CombatRatingsAI.get_ship_rating(ship_id)
556                                debug("Considering to release ship %d with rating %.1f", ship_id, ship_rating)
557                                if ship_rating > allowance:
558                                    debug("Remaining rating insufficient. Not released.")
559                                    continue
560                                debug("Splitting from fleet.")
561                                new_fleet_id = FleetUtilsAI.split_ship_from_fleet(fleet_id, ship_id)
562                                if assertion_fails(new_fleet_id and new_fleet_id != INVALID_ID):
563                                    break
564                                new_fleets.append(new_fleet_id)
565                                fleet_remaining_rating = CombatRatingsAI.rating_difference(
566                                    fleet_remaining_rating, ship_rating)
567                                allowance = CombatRatingsAI.rating_difference(
568                                    fleet_remaining_rating, fleet_min_rating)
569                                debug("Remaining fleet rating: %.1f - Allowance: %.1f",
570                                      fleet_remaining_rating, allowance)
571                            if new_fleets:
572                                aistate.get_fleet_role(fleet_id, force_new=True)
573                                aistate.update_fleet_rating(fleet_id)
574                                aistate.ensure_have_fleet_missions(new_fleets)
575                        else:
576                            debug("Planetary defenses are deemed sufficient. Release fleet.")
577                            new_fleets = FleetUtilsAI.split_fleet(self.fleet.id)
578
579                    new_military_fleets = []
580                    for fleet_id in new_fleets:
581                        if aistate.get_fleet_role(fleet_id) in COMBAT_MISSION_TYPES:
582                            new_military_fleets.append(fleet_id)
583                    allocations = []
584                    if new_military_fleets:
585                        allocations = MilitaryAI.get_military_fleets(
586                            mil_fleets_ids=new_military_fleets,
587                            try_reset=False,
588                            thisround="Fleet Reassignment %s" % new_military_fleets
589                        )
590                    if allocations:
591                        MilitaryAI.assign_military_fleets_to_systems(use_fleet_id_list=new_military_fleets,
592                                                                     allocations=allocations)
593
594    def _portion_of_fleet_needed_here(self):
595        """Calculate the portion of the fleet needed in target system considering enemy forces."""
596        # TODO check rating against planets
597        if assertion_fails(self.type in COMBAT_MISSION_TYPES, msg=str(self)):
598            return 0
599        if assertion_fails(self.target and self.target.id != INVALID_ID, msg=str(self)):
600            return 0
601        system_id = self.target.id
602        aistate = get_aistate()
603        local_defenses = MilitaryAI.get_my_defense_rating_in_system(system_id)
604        potential_threat = CombatRatingsAI.combine_ratings(
605            MilitaryAI.get_system_local_threat(system_id),
606            MilitaryAI.get_system_neighbor_threat(system_id)
607        )
608        universe = fo.getUniverse()
609        system = universe.getSystem(system_id)
610
611        # tally planetary defenses
612        total_defense = total_shields = 0
613        for planet_id in system.planetIDs:
614            planet = universe.getPlanet(planet_id)
615            total_defense += planet.currentMeterValue(fo.meterType.defense)
616            total_shields += planet.currentMeterValue(fo.meterType.shield)
617        planetary_ratings = total_defense * (total_shields + total_defense)
618        potential_threat += planetary_ratings  # TODO: rewrite to return min rating vs planets as well
619
620        # consider safety factor just once here rather than everywhere below
621        safety_factor = aistate.character.military_safety_factor()
622        potential_threat *= safety_factor
623
624        # TODO: Rate against specific threat here
625        fleet_rating = CombatRatingsAI.get_fleet_rating(self.fleet.id)
626        return CombatRatingsAI.rating_needed(potential_threat, local_defenses) / float(max(fleet_rating, 1.))
627
628    def generate_fleet_orders(self):
629        """generates AIFleetOrders from fleets targets to accomplish"""
630        universe = fo.getUniverse()
631        fleet_id = self.fleet.id
632        fleet = universe.getFleet(fleet_id)
633        if (not fleet) or fleet.empty or (fleet_id in universe.destroyedObjectIDs(fo.empireID())):
634            # fleet was probably merged into another or was destroyed
635            get_aistate().delete_fleet_info(fleet_id)
636            return
637
638        # TODO: priority
639        self.clear_fleet_orders()
640        system_id = fleet.systemID
641        start_sys_id = [fleet.nextSystemID, system_id][system_id >= 0]
642        # if fleet doesn't have any mission,
643        # then repair if needed or resupply if is current location not in supplyable system
644        empire = fo.getEmpire()
645        fleet_supplyable_system_ids = empire.fleetSupplyableSystemIDs
646        # if (not self.hasAnyAIMissionTypes()):
647        if not self.target and (system_id not in set(AIstate.colonyTargetedSystemIDs +
648                                                     AIstate.outpostTargetedSystemIDs +
649                                                     AIstate.invasionTargetedSystemIDs)):
650            if self._need_repair():
651                repair_fleet_order = MoveUtilsAI.get_repair_fleet_order(self.fleet, start_sys_id)
652                if repair_fleet_order and repair_fleet_order.is_valid():
653                    self.orders.append(repair_fleet_order)
654            cur_fighter_capacity, max_fighter_capacity = FleetUtilsAI.get_fighter_capacity_of_fleet(fleet_id)
655            if (fleet.fuel < fleet.maxFuel or cur_fighter_capacity < max_fighter_capacity
656                    and self.get_location_target().id not in fleet_supplyable_system_ids):
657                resupply_fleet_order = MoveUtilsAI.get_resupply_fleet_order(self.fleet, self.get_location_target())
658                if resupply_fleet_order.is_valid():
659                    self.orders.append(resupply_fleet_order)
660            return  # no targets
661
662        if self.target:
663            # for some targets fleet has to visit systems and therefore fleet visit them
664
665            system_to_visit = (self.target.get_system() if not self.type == MissionType.PROTECT_REGION
666                               else TargetSystem(self._get_target_for_protection_mission()))
667            if not system_to_visit:
668                return
669            orders_to_visit_systems = MoveUtilsAI.create_move_orders_to_system(self.fleet, system_to_visit)
670            # TODO: if fleet doesn't have enough fuel to get to final target, consider resetting Mission
671            for fleet_order in orders_to_visit_systems:
672                self.orders.append(fleet_order)
673
674            # also generate appropriate final orders
675            fleet_order = self._get_fleet_order_from_target(self.type,
676                                                            self.target if not self.type == MissionType.PROTECT_REGION
677                                                            else system_to_visit)
678            self.orders.append(fleet_order)
679
680    def _need_repair(self, repair_limit=0.70):
681        """Check if fleet needs to be repaired.
682
683         If the fleet is already at a system where it can be repaired, stay there until fully repaired.
684         Otherwise, repair if fleet health is below specified *repair_limit*.
685         For military fleets, there is a special evaluation called, cf. *MilitaryAI.avail_mil_needing_repair()*
686
687         :param repair_limit: percentage of health below which the fleet is sent to repair
688         :type repair_limit: float
689         :return: True if fleet needs repair
690         :rtype: bool
691        """
692        # TODO: More complex evaluation if fleet needs repair (consider self-repair, distance, threat, mission...)
693        fleet_id = self.fleet.id
694        # if we are already at a system where we can repair, make sure we use it...
695        system = self.fleet.get_system()
696        # TODO starlane obstruction is not considered in the next call
697        nearest_dock = MoveUtilsAI.get_best_drydock_system_id(system.id, fleet_id)
698        if nearest_dock == system.id:
699            repair_limit = 0.99
700        # if combat fleet, use military repair check
701        if get_aistate().get_fleet_role(fleet_id) in COMBAT_MISSION_TYPES:
702            return fleet_id in MilitaryAI.avail_mil_needing_repair([fleet_id], on_mission=bool(self.orders),
703                                                                   repair_limit=repair_limit)[0]
704        # TODO: Allow to split fleet to send only damaged ships to repair
705        ships_cur_health, ships_max_health = FleetUtilsAI.get_current_and_max_structure(fleet_id)
706        return ships_cur_health < repair_limit * ships_max_health
707
708    def get_location_target(self):
709        """system AITarget where fleet is or will be"""
710        # TODO add parameter turn
711        fleet = fo.getUniverse().getFleet(self.fleet.id)
712        system_id = fleet.systemID
713        if system_id >= 0:
714            return TargetSystem(system_id)
715        else:  # in starlane, so return next system
716            return TargetSystem(fleet.nextSystemID)
717
718    def __eq__(self, other):
719        return isinstance(other, self.__class__) and self.fleet == other.target
720
721    def __hash__(self):
722        return hash(self.fleet)
723
724    def __str__(self):
725        fleet = self.fleet.get_object()
726
727        fleet_id = self.fleet.id
728        return "%-25s [%-11s] ships: %2d; total rating: %4d; target: %s" % (fleet,
729                                                                            "NONE" if self.type is None else self.type,
730                                                                            (fleet and len(fleet.shipIDs)) or 0,
731                                                                            CombatRatingsAI.get_fleet_rating(fleet_id),
732                                                                            self.target or 'no target')
733
734    def _get_target_for_protection_mission(self):
735        """Get a target for a PROTECT_REGION mission.
736
737        1) If primary target (system target of this mission) is under attack, move to primary target.
738        2) If neighbors of primary target have local enemy forces weaker than this fleet, may move to attack
739        3) If no neighboring fleets or strongest enemy force is too strong, move to defend primary target
740        """
741        # TODO: Also check fleet rating vs planets in decision making below not only vs fleets
742        universe = fo.getUniverse()
743        primary_objective = self.target.id
744        # TODO: Rate against specific threats
745        fleet_rating = CombatRatingsAI.get_fleet_rating(self.fleet.id)
746        debug("%s finding target for protection mission (primary target %s). Fleet Rating: %.1f",
747              self.fleet, self.target, fleet_rating)
748        immediate_threat = MilitaryAI.get_system_local_threat(primary_objective)
749        if immediate_threat:
750            debug("  Immediate threat! Moving to primary mission target")
751            return primary_objective
752        else:
753            debug("  No immediate threats.")
754            # Try to eliminate neighbouring fleets
755            neighbors = universe.getImmediateNeighbors(primary_objective, fo.empireID())
756            threat_list = sorted(map(
757                lambda x: (MilitaryAI.get_system_local_threat(x), x),
758                neighbors
759            ), reverse=True)
760
761            if not threat_list:
762                debug("  No neighbors (?!). Moving to primary mission target")
763                return primary_objective
764            else:
765                debug("  Neighboring threats:")
766                for threat, sys_id in threat_list:
767                    debug("    %s - %.1f", TargetSystem(sys_id), threat)
768            top_threat, candidate_system = threat_list[0]
769            if not top_threat:
770                # TODO: Move into second ring but needs more careful evaluation
771                # For now, consider staying at the current location if enemy
772                # owns a planet here which we can bombard.
773                current_system_id = self.fleet.get_current_system_id()
774                if current_system_id in neighbors:
775                    system = universe.getSystem(current_system_id)
776                    if assertion_fails(system is not None):
777                        return primary_objective
778                    empire_id = fo.empireID()
779                    for planet_id in system.planetIDs:
780                        planet = universe.getPlanet(planet_id)
781                        if (planet and
782                                not planet.ownedBy(empire_id) and
783                                not planet.unowned):
784                            debug("Currently no neighboring threats. "
785                                  "Staying for bombardment of planet %s", planet)
786                            return current_system_id
787
788                # TODO consider attacking neighboring, non-military fleets
789                # - needs more careful evaluation against neighboring threats
790                # empire_id = fo.empireID()
791                # for sys_id in neighbors:
792                #     system = universe.getSystem(sys_id)
793                #     if assertion_fails(system is not None):
794                #         continue
795                #     local_fleets = system.fleetIDs
796                #     for fleet_id in local_fleets:
797                #         fleet = universe.getFleet(fleet_id)
798                #         if not fleet or fleet.ownedBy(empire_id):
799                #             continue
800                #         return sys_id
801
802                debug("No neighboring threats. Moving to primary mission target")
803                return primary_objective
804
805            # TODO rate against threat in target system
806            # TODO only engage if can reach in 1 turn or leaves sufficient defense behind
807            safety_factor = get_aistate().character.military_safety_factor()
808            if fleet_rating < safety_factor*top_threat:
809                debug("  Neighboring threat is too powerful. Moving to primary mission target")
810                return primary_objective  # do not engage!
811
812            debug("  Engaging neighboring threat: %s", TargetSystem(candidate_system))
813            return candidate_system
814