1import math
2from logging import debug, info, warning
3
4import freeOrionAIInterface as fo
5
6from aistate_interface import get_aistate
7import AIDependencies
8import AIstate
9import ColonisationAI
10import CombatRatingsAI
11import EspionageAI
12import FleetUtilsAI
13import MilitaryAI
14import PlanetUtilsAI
15import ProductionAI
16from AIDependencies import INVALID_ID, Tags
17from EnumsAI import MissionType, PriorityType
18from common.print_utils import Table, Text, Float
19from freeorion_tools import tech_is_complete, AITimer, get_partial_visibility_turn, get_species_tag_grade
20from target import TargetPlanet, TargetSystem
21from turn_state import state
22
23MAX_BASE_TROOPERS_GOOD_INVADERS = 20
24MAX_BASE_TROOPERS_POOR_INVADERS = 10
25_TROOPS_SAFETY_MARGIN = 1  # try to send this amount of additional troops to account for uncertainties in calculation
26MIN_INVASION_SCORE = 20
27
28invasion_timer = AITimer('get_invasion_fleets()', write_log=False)
29
30
31def get_invasion_fleets():
32    invasion_timer.start("gathering initial info")
33    universe = fo.getUniverse()
34    empire = fo.getEmpire()
35    empire_id = fo.empireID()
36
37    home_system_id = PlanetUtilsAI.get_capital_sys_id()
38    aistate = get_aistate()
39    visible_system_ids = list(aistate.visInteriorSystemIDs) + list(aistate.visBorderSystemIDs)
40
41    if home_system_id != INVALID_ID:
42        accessible_system_ids = [sys_id for sys_id in visible_system_ids if
43                                 (sys_id != INVALID_ID) and universe.systemsConnected(sys_id, home_system_id,
44                                                                                      empire_id)]
45    else:
46        debug("Empire has no identifiable homeworld; will treat all visible planets as accessible.")
47        # TODO: check if any troop ships owned, use their system as home system
48        accessible_system_ids = visible_system_ids
49
50    acessible_planet_ids = PlanetUtilsAI.get_planets_in__systems_ids(accessible_system_ids)
51    all_owned_planet_ids = PlanetUtilsAI.get_all_owned_planet_ids(acessible_planet_ids)  # includes unpopulated outposts
52    all_populated_planets = PlanetUtilsAI.get_populated_planet_ids(acessible_planet_ids)  # includes unowned natives
53    empire_owned_planet_ids = PlanetUtilsAI.get_owned_planets_by_empire(universe.planetIDs)
54    invadable_planet_ids = set(all_owned_planet_ids).union(all_populated_planets) - set(empire_owned_planet_ids)
55
56    invasion_targeted_planet_ids = get_invasion_targeted_planet_ids(universe.planetIDs, MissionType.INVASION)
57    invasion_targeted_planet_ids.extend(
58        get_invasion_targeted_planet_ids(universe.planetIDs, MissionType.ORBITAL_INVASION))
59    all_invasion_targeted_system_ids = set(PlanetUtilsAI.get_systems(invasion_targeted_planet_ids))
60
61    invasion_fleet_ids = FleetUtilsAI.get_empire_fleet_ids_by_role(MissionType.INVASION)
62    num_invasion_fleets = len(FleetUtilsAI.extract_fleet_ids_without_mission_types(invasion_fleet_ids))
63
64    debug("Current Invasion Targeted SystemIDs: %s" % PlanetUtilsAI.sys_name_ids(AIstate.invasionTargetedSystemIDs))
65    debug("Current Invasion Targeted PlanetIDs: %s" % PlanetUtilsAI.planet_string(invasion_targeted_planet_ids))
66    debug(invasion_fleet_ids and "Invasion Fleet IDs: %s" % invasion_fleet_ids or "Available Invasion Fleets: 0")
67    debug("Invasion Fleets Without Missions: %s" % num_invasion_fleets)
68
69    invasion_timer.start("planning troop base production")
70    reserved_troop_base_targets = []
71    if aistate.character.may_invade_with_bases():
72        available_pp = {}
73        for el in empire.planetsWithAvailablePP:  # keys are sets of ints; data is doubles
74            avail_pp = el.data()
75            for pid in el.key():
76                available_pp[pid] = avail_pp
77        # For planning base trooper invasion targets we have a two-pass system.  (1) In the first pass we consider all
78        # the invasion targets and figure out which ones appear to be suitable for using base troopers against (i.e., we
79        # already have a populated planet in the same system that could build base troopers) and we have at least a
80        # minimal amount of PP available, and (2) in the second pass we go through the reserved base trooper target list
81        # and check to make sure that there does not appear to be too much military action still needed before the
82        # target is ready to be invaded, we double check that not too many base troopers would be needed, and if things
83        # look clear then we queue up the base troopers on the Production Queue and keep track of where we are building
84        # them, and how many; we may also disqualify and remove previously qualified targets (in case, for example,
85        # we lost our base trooper source planet since it was first added to list).
86        #
87        # For planning and tracking base troopers under construction, we use a dictionary store in
88        # get_aistate().qualifyingTroopBaseTargets, keyed by the invasion target planet ID.  We only store values
89        # for invasion targets that appear likely to be suitable for base trooper use, and store a 2-item list.
90        # The first item in this list is the ID of the planet where we expect to build the base troopers, and the second
91        # entry initially is set to INVALID_ID (-1).  The presence of this entry in qualifyingTroopBaseTargets
92        # flags this target as being reserved as a base-trooper invasion target.
93        # In the second pass, if/when we actually start construction, then we modify the record, replacing that second
94        # value with the ID of the planet where the troopers are actually being built.  (Right now that is always the
95        # same as the source planet originally identified, but we could consider reevaluating that, or use that second
96        # value to instead record how many base troopers have been queued, so that on later turns we can assess if the
97        # process got delayed & perhaps more troopers need to be queued).
98        secure_ai_fleet_missions = aistate.get_fleet_missions_with_any_mission_types([MissionType.SECURE,
99                                                                                      MissionType.MILITARY])
100
101        # Pass 1: identify qualifying base troop invasion targets
102        for pid in invadable_planet_ids:  # TODO: reorganize
103            if pid in aistate.qualifyingTroopBaseTargets:
104                continue
105            planet = universe.getPlanet(pid)
106            if not planet:
107                continue
108            sys_id = planet.systemID
109            sys_partial_vis_turn = get_partial_visibility_turn(sys_id)
110            planet_partial_vis_turn = get_partial_visibility_turn(pid)
111            if planet_partial_vis_turn < sys_partial_vis_turn:
112                continue
113            best_base_planet = INVALID_ID
114            best_trooper_count = 0
115            for pid2 in state.get_empire_planets_by_system(sys_id, include_outposts=False):
116                if available_pp.get(pid2, 0) < 2:  # TODO: improve troop base PP sufficiency determination
117                    break
118                planet2 = universe.getPlanet(pid2)
119                if not planet2 or planet2.speciesName not in ColonisationAI.empire_ship_builders:
120                    continue
121                best_base_trooper_here = \
122                    ProductionAI.get_best_ship_info(PriorityType.PRODUCTION_ORBITAL_INVASION, pid2)[1]
123                if not best_base_trooper_here:
124                    continue
125                troops_per_ship = best_base_trooper_here.troopCapacity
126                if not troops_per_ship:
127                    continue
128                species_troop_grade = get_species_tag_grade(planet2.speciesName, Tags.ATTACKTROOPS)
129                troops_per_ship = CombatRatingsAI.weight_attack_troops(troops_per_ship, species_troop_grade)
130                if troops_per_ship > best_trooper_count:
131                    best_base_planet = pid2
132                    best_trooper_count = troops_per_ship
133            if best_base_planet != INVALID_ID:
134                aistate.qualifyingTroopBaseTargets.setdefault(pid, [best_base_planet, INVALID_ID])
135
136        # Pass 2: for each target previously identified for base troopers, check that still qualifies and
137        # check how many base troopers would be needed; if reasonable then queue up the troops and record this in
138        # get_aistate().qualifyingTroopBaseTargets
139        for pid in list(aistate.qualifyingTroopBaseTargets.keys()):
140            planet = universe.getPlanet(pid)
141            if planet and planet.owner == empire_id:
142                del aistate.qualifyingTroopBaseTargets[pid]
143                continue
144            if pid in invasion_targeted_planet_ids:  # TODO: consider overriding standard invasion mission
145                continue
146            if aistate.qualifyingTroopBaseTargets[pid][1] != -1:
147                reserved_troop_base_targets.append(pid)
148                if planet:
149                    all_invasion_targeted_system_ids.add(planet.systemID)
150                # TODO: evaluate changes to situation, any more troops needed, etc.
151                continue  # already building for here
152            _, planet_troops = evaluate_invasion_planet(pid, secure_ai_fleet_missions, True)
153            sys_id = planet.systemID
154            this_sys_status = aistate.systemStatus.get(sys_id, {})
155            troop_tally = 0
156            for _fid in this_sys_status.get('myfleets', []):
157                troop_tally += FleetUtilsAI.count_troops_in_fleet(_fid)
158            if troop_tally > planet_troops:  # base troopers appear unneeded
159                del aistate.qualifyingTroopBaseTargets[pid]
160                continue
161            if (planet.currentMeterValue(fo.meterType.shield) > 0 and
162                    (this_sys_status.get('myFleetRating', 0) < 0.8 * this_sys_status.get('totalThreat', 0) or
163                     this_sys_status.get('myFleetRatingVsPlanets', 0) < this_sys_status.get('planetThreat', 0))):
164                # this system not secured, so ruling out invasion base troops for now
165                # don't immediately delete from qualifyingTroopBaseTargets or it will be opened up for regular troops
166                continue
167            loc = aistate.qualifyingTroopBaseTargets[pid][0]
168            best_base_trooper_here = ProductionAI.get_best_ship_info(PriorityType.PRODUCTION_ORBITAL_INVASION, loc)[1]
169            loc_planet = universe.getPlanet(loc)
170            if best_base_trooper_here is None:  # shouldn't be possible at this point, but just to be safe
171                warning("Could not find a suitable orbital invasion design at %s" % loc_planet)
172                continue
173            # TODO: have TroopShipDesigner give the expected number of troops including species effects directly
174            troops_per_ship = best_base_trooper_here.troopCapacity
175            species_troop_grade = get_species_tag_grade(loc_planet.speciesName, Tags.ATTACKTROOPS)
176            troops_per_ship = CombatRatingsAI.weight_attack_troops(troops_per_ship, species_troop_grade)
177            if not troops_per_ship:
178                warning("The best orbital invasion design at %s seems not to have any troop capacity." % loc_planet)
179                continue
180            _, col_design, build_choices = ProductionAI.get_best_ship_info(PriorityType.PRODUCTION_ORBITAL_INVASION,
181                                                                           loc)
182            if not col_design:
183                continue
184            if loc not in build_choices:
185                warning('Best troop design %s can not be produced at planet with id: %s' % (col_design, build_choices))
186                continue
187            n_bases = math.ceil((planet_troops + 1) / troops_per_ship)  # TODO: reconsider this +1 safety factor
188            # TODO: evaluate cost and time-to-build of best base trooper here versus cost and time-to-build-and-travel
189            # for best regular trooper elsewhere
190            # For now, we assume what building base troopers is best so long as either (1) we would need no more than
191            # MAX_BASE_TROOPERS_POOR_INVADERS base troop ships, or (2) our base troopers have more than 1 trooper per
192            # ship and we would need no more than MAX_BASE_TROOPERS_GOOD_INVADERS base troop ships
193            if (n_bases > MAX_BASE_TROOPERS_POOR_INVADERS or
194                    (troops_per_ship > 1 and n_bases > MAX_BASE_TROOPERS_GOOD_INVADERS)):
195                debug("ruling out base invasion troopers for %s due to high number (%d) required." % (planet, n_bases))
196                del aistate.qualifyingTroopBaseTargets[pid]
197                continue
198            debug("Invasion base planning, need %d troops at %d per ship, will build %d ships." % (
199                (planet_troops + 1), troops_per_ship, n_bases))
200            retval = fo.issueEnqueueShipProductionOrder(col_design.id, loc)
201            debug("Enqueueing %d Troop Bases at %s for %s" % (n_bases, PlanetUtilsAI.planet_string(loc),
202                                                              PlanetUtilsAI.planet_string(pid)))
203            if retval != 0:
204                all_invasion_targeted_system_ids.add(planet.systemID)
205                reserved_troop_base_targets.append(pid)
206                aistate.qualifyingTroopBaseTargets[pid][1] = loc
207                fo.issueChangeProductionQuantityOrder(empire.productionQueue.size - 1, 1, int(n_bases))
208                fo.issueRequeueProductionOrder(empire.productionQueue.size - 1, 0)
209
210    invasion_timer.start("evaluating target planets")
211    # TODO: check if any invasion_targeted_planet_ids need more troops assigned
212    evaluated_planet_ids = list(
213        set(invadable_planet_ids) - set(invasion_targeted_planet_ids) - set(reserved_troop_base_targets))
214    evaluated_planets = assign_invasion_values(evaluated_planet_ids)
215
216    sorted_planets = [(pid, pscore % 10000, ptroops) for pid, (pscore, ptroops) in evaluated_planets.items()]
217    sorted_planets.sort(key=lambda x: x[1], reverse=True)
218    sorted_planets = [(pid, pscore % 10000, ptroops) for pid, pscore, ptroops in sorted_planets]
219
220    invasion_table = Table([Text('Planet'), Float('Score'), Text('Species'), Float('Troops')],
221                           table_name="Potential Targets for Invasion Turn %d" % fo.currentTurn())
222
223    for pid, pscore, ptroops in sorted_planets:
224        planet = universe.getPlanet(pid)
225        invasion_table.add_row([
226            planet,
227            pscore,
228            planet and planet.speciesName or "unknown",
229            ptroops
230        ])
231    info(invasion_table)
232
233    sorted_planets = [x for x in sorted_planets if x[1] > 0]
234    # export opponent planets for other AI modules
235    AIstate.opponentPlanetIDs = [pid for pid, __, __ in sorted_planets]
236    AIstate.invasionTargets = sorted_planets
237
238    # export invasion targeted systems for other AI modules
239    AIstate.invasionTargetedSystemIDs = list(all_invasion_targeted_system_ids)
240    invasion_timer.stop(section_name="evaluating %d target planets" % (len(evaluated_planet_ids)))
241    invasion_timer.stop_print_and_clear()
242
243
244def get_invasion_targeted_planet_ids(planet_ids, mission_type):
245    invasion_feet_missions = get_aistate().get_fleet_missions_with_any_mission_types([mission_type])
246    targeted_planets = []
247    for pid in planet_ids:
248        # add planets that are target of a mission
249        for mission in invasion_feet_missions:
250            target = TargetPlanet(pid)
251            if mission.has_target(mission_type, target):
252                targeted_planets.append(pid)
253    return targeted_planets
254
255
256def retaliation_risk_factor(empire_id):
257    """A multiplicative adjustment to planet scores to account for risk of retaliation from planet owner."""
258    # TODO implement (in militaryAI) actual military risk assessment of other empires
259    if empire_id == -1:  # unowned
260        return 1.5  # since no risk of retaliation, increase score
261    else:
262        return 1.0
263
264
265def assign_invasion_values(planet_ids):
266    """Creates a dictionary that takes planet_ids as key and their invasion score as value."""
267    empire_id = fo.empireID()
268    planet_values = {}
269    neighbor_values = {}
270    neighbor_val_ratio = .95
271    universe = fo.getUniverse()
272    secure_missions = get_aistate().get_fleet_missions_with_any_mission_types([MissionType.SECURE,
273                                                                               MissionType.MILITARY])
274    for pid in planet_ids:
275        planet_values[pid] = neighbor_values.setdefault(pid, evaluate_invasion_planet(pid, secure_missions))
276        debug("planet %d, values %s", pid, planet_values[pid])
277        planet = universe.getPlanet(pid)
278        species_name = (planet and planet.speciesName) or ""
279        species = fo.getSpecies(species_name)
280        if species and species.canProduceShips:
281            system = universe.getSystem(planet.systemID)
282            if not system:
283                continue
284            planet_industries = {}
285            for pid2 in system.planetIDs:
286                planet2 = universe.getPlanet(pid2)
287                species_name2 = (planet2 and planet2.speciesName) or ""
288                species2 = fo.getSpecies(species_name2)
289                if species2 and species2.canProduceShips:
290                    # to prevent divide-by-zero
291                    planet_industries[pid2] = planet2.initialMeterValue(fo.meterType.industry) + 0.1
292            industry_ratio = planet_industries[pid] / max(planet_industries.values())
293            for pid2 in system.planetIDs:
294                if pid2 == pid:
295                    continue
296                planet2 = universe.getPlanet(pid2)
297                # TODO check for allies
298                if (planet2 and (planet2.owner != empire_id) and
299                        ((planet2.owner != -1) or (planet2.initialMeterValue(fo.meterType.population) > 0))):
300                    planet_values[pid][0] += (
301                        industry_ratio *
302                        neighbor_val_ratio *
303                        (neighbor_values.setdefault(pid2, evaluate_invasion_planet(pid2, secure_missions))[0])
304                    )
305    return planet_values
306
307
308def evaluate_invasion_planet(planet_id, secure_fleet_missions, verbose=True):
309    """Return the invasion value (score, troops) of a planet."""
310    universe = fo.getUniverse()
311    empire_id = fo.empireID()
312    detail = []
313
314    planet = universe.getPlanet(planet_id)
315    if planet is None:
316        debug("Invasion AI couldn't access any info for planet id %d" % planet_id)
317        return [0, 0]
318
319    system_id = planet.systemID
320
321    # by using the following instead of simply relying on stealth meter reading, can (sometimes) plan ahead even if
322    # planet is temporarily shrouded by an ion storm
323    predicted_detectable = EspionageAI.colony_detectable_by_empire(planet_id, empire=fo.empireID(),
324                                                                   default_result=False)
325    if not predicted_detectable:
326        if get_partial_visibility_turn(planet_id) < fo.currentTurn():
327            debug("InvasionAI predicts planet id %d to be stealthed" % planet_id)
328            return [0, 0]
329        else:
330            debug("InvasionAI predicts planet id %d to be stealthed" % planet_id +
331                  ", but somehow have current visibity anyway, will still consider as target")
332
333    # Check if the target planet was extra-stealthed somehow its system was last viewed
334    # this test below may augment the tests above, but can be thrown off by temporary combat-related sighting
335    system_last_seen = get_partial_visibility_turn(planet_id)
336    planet_last_seen = get_partial_visibility_turn(system_id)
337    if planet_last_seen < system_last_seen:
338        # TODO: track detection strength, order new scouting when it goes up
339        debug("Invasion AI considering planet id %d (stealthed at last view), still proceeding." % planet_id)
340
341    # get a baseline evaluation of the planet as determined by ColonisationAI
342    species_name = planet.speciesName
343    species = fo.getSpecies(species_name)
344    empire_research_list = tuple(element.tech for element in fo.getEmpire().researchQueue)
345    if not species or AIDependencies.TAG_DESTROYED_ON_CONQUEST in species.tags:
346        # this call iterates over this Empire's available species with which it could colonize after an invasion
347        planet_eval = ColonisationAI.assign_colonisation_values([planet_id], MissionType.INVASION, None, detail)
348        colony_base_value = max(0.75 * planet_eval.get(planet_id, [0])[0],
349                                ColonisationAI.evaluate_planet(
350                                    planet_id, MissionType.OUTPOST, None, detail, empire_research_list))
351    else:
352        colony_base_value = ColonisationAI.evaluate_planet(
353            planet_id, MissionType.INVASION, species_name, detail, empire_research_list)
354
355    # Add extra score for all buildings on the planet
356    building_values = {"BLD_IMPERIAL_PALACE": 1000,
357                       "BLD_CULTURE_ARCHIVES": 1000,
358                       "BLD_AUTO_HISTORY_ANALYSER": 100,
359                       "BLD_SHIPYARD_BASE": 100,
360                       "BLD_SHIPYARD_ORG_ORB_INC": 200,
361                       "BLD_SHIPYARD_ORG_XENO_FAC": 200,
362                       "BLD_SHIPYARD_ORG_CELL_GRO_CHAMB": 200,
363                       "BLD_SHIPYARD_CON_NANOROBO": 300,
364                       "BLD_SHIPYARD_CON_GEOINT": 400,
365                       "BLD_SHIPYARD_CON_ADV_ENGINE": 1000,
366                       "BLD_SHIPYARD_AST": 300,
367                       "BLD_SHIPYARD_AST_REF": 1000,
368                       "BLD_SHIPYARD_ENRG_SOLAR": 1500,
369                       "BLD_INDUSTRY_CENTER": 500,
370                       "BLD_GAS_GIANT_GEN": 200,
371                       "BLD_SOL_ORB_GEN": 800,
372                       "BLD_BLACK_HOLE_POW_GEN": 2000,
373                       "BLD_ENCLAVE_VOID": 500,
374                       "BLD_NEUTRONIUM_EXTRACTOR": 2000,
375                       "BLD_NEUTRONIUM_SYNTH": 2000,
376                       "BLD_NEUTRONIUM_FORGE": 1000,
377                       "BLD_CONC_CAMP": 100,
378                       "BLD_BIOTERROR_PROJECTOR": 1000,
379                       "BLD_SHIPYARD_ENRG_COMP": 3000,
380                       }
381    bld_tally = 0
382    for bldType in [universe.getBuilding(bldg).buildingTypeName for bldg in planet.buildingIDs]:
383        bval = building_values.get(bldType, 50)
384        bld_tally += bval
385        detail.append("%s: %d" % (bldType, bval))
386
387    # Add extra score for unlocked techs when we conquer the species
388    tech_tally = 0
389    value_per_pp = 4
390    for unlocked_tech in AIDependencies.SPECIES_TECH_UNLOCKS.get(species_name, []):
391        if not tech_is_complete(unlocked_tech):
392            rp_cost = fo.getTech(unlocked_tech).researchCost(empire_id)
393            tech_value = value_per_pp * rp_cost
394            tech_tally += tech_value
395            detail.append("%s: %d" % (unlocked_tech, tech_value))
396
397    max_jumps = 8
398    capitol_id = PlanetUtilsAI.get_capital()
399    least_jumps_path = []
400    clear_path = True
401    if capitol_id:
402        homeworld = universe.getPlanet(capitol_id)
403        if homeworld and homeworld.systemID != INVALID_ID and system_id != INVALID_ID:
404            least_jumps_path = list(universe.leastJumpsPath(homeworld.systemID, system_id, empire_id))
405            max_jumps = len(least_jumps_path)
406    aistate = get_aistate()
407    system_status = aistate.systemStatus.get(system_id, {})
408    system_fleet_treat = system_status.get('fleetThreat', 1000)
409    system_monster_threat = system_status.get('monsterThreat', 0)
410    sys_total_threat = system_fleet_treat + system_monster_threat + system_status.get('planetThreat', 0)
411    max_path_threat = system_fleet_treat
412    mil_ship_rating = MilitaryAI.cur_best_mil_ship_rating()
413    for path_sys_id in least_jumps_path:
414        path_leg_status = aistate.systemStatus.get(path_sys_id, {})
415        path_leg_threat = path_leg_status.get('fleetThreat', 1000) + path_leg_status.get('monsterThreat', 0)
416        if path_leg_threat > 0.5 * mil_ship_rating:
417            clear_path = False
418            if path_leg_threat > max_path_threat:
419                max_path_threat = path_leg_threat
420
421    pop = planet.currentMeterValue(fo.meterType.population)
422    target_pop = planet.currentMeterValue(fo.meterType.targetPopulation)
423    troops = planet.currentMeterValue(fo.meterType.troops)
424    troop_regen = planet.currentMeterValue(fo.meterType.troops) - planet.initialMeterValue(fo.meterType.troops)
425    max_troops = planet.currentMeterValue(fo.meterType.maxTroops)
426    # TODO: refactor troop determination into function for use in mid-mission updates and also consider defender techs
427    max_troops += AIDependencies.TROOPS_PER_POP * (target_pop - pop)
428
429    this_system = universe.getSystem(system_id)
430    secure_targets = [system_id] + list(this_system.planetIDs)
431    system_secured = False
432    for mission in secure_fleet_missions:
433        if system_secured:
434            break
435        secure_fleet_id = mission.fleet.id
436        s_fleet = universe.getFleet(secure_fleet_id)
437        if not s_fleet or s_fleet.systemID != system_id:
438            continue
439        if mission.type in [MissionType.SECURE, MissionType.MILITARY]:
440            target_obj = mission.target.get_object()
441            if target_obj is not None and target_obj.id in secure_targets:
442                system_secured = True
443                break
444    system_secured = system_secured and system_status.get('myFleetRating', 0)
445
446    if verbose:
447        debug("Invasion eval of %s\n"
448              " - maxShields: %.1f\n"
449              " - sysFleetThreat: %.1f\n"
450              " - sysMonsterThreat: %.1f",
451              planet, planet.currentMeterValue(fo.meterType.maxShield), system_fleet_treat, system_monster_threat)
452    enemy_val = 0
453    if planet.owner != -1:  # value in taking this away from an enemy
454        enemy_val = 20 * (planet.currentMeterValue(fo.meterType.targetIndustry) +
455                          2*planet.currentMeterValue(fo.meterType.targetResearch))
456
457    # devalue invasions that would require too much military force
458    preferred_max_portion = MilitaryAI.get_preferred_max_military_portion_for_single_battle()
459    total_max_mil_rating = MilitaryAI.get_concentrated_tot_mil_rating()
460    threat_exponent = 2  # TODO: make this a character trait; higher aggression with a lower exponent
461    threat_factor = min(1, preferred_max_portion * total_max_mil_rating/(sys_total_threat+0.001))**threat_exponent
462
463    design_id, _, locs = ProductionAI.get_best_ship_info(PriorityType.PRODUCTION_INVASION)
464    if not locs or not universe.getPlanet(locs[0]):
465        # We are in trouble anyway, so just calculate whatever approximation...
466        build_time = 4
467        planned_troops = troops if system_secured else min(troops + troop_regen*(max_jumps + build_time), max_troops)
468        planned_troops += .01  # we must attack with more troops than there are defenders
469        troop_cost = math.ceil((planned_troops+_TROOPS_SAFETY_MARGIN) / 6.0) * 20 * FleetUtilsAI.get_fleet_upkeep()
470    else:
471        loc = locs[0]
472        species_here = universe.getPlanet(loc).speciesName
473        design = fo.getShipDesign(design_id)
474        cost_per_ship = design.productionCost(empire_id, loc)
475        build_time = design.productionTime(empire_id, loc)
476        troops_per_ship = CombatRatingsAI.weight_attack_troops(
477            design.troopCapacity, get_species_tag_grade(species_here, Tags.ATTACKTROOPS))
478        planned_troops = troops if system_secured else min(troops + troop_regen*(max_jumps + build_time), max_troops)
479        planned_troops += .01  # we must attack with more troops than there are defenders
480        ships_needed = math.ceil((planned_troops+_TROOPS_SAFETY_MARGIN) / float(troops_per_ship))
481        troop_cost = ships_needed * cost_per_ship  # fleet upkeep is already included in query from server
482
483    # apply some bias to expensive operations
484    normalized_cost = float(troop_cost) / max(fo.getEmpire().productionPoints, 1)
485    normalized_cost = max(1., normalized_cost)
486    cost_score = (normalized_cost**2 / 50.0) * troop_cost
487
488    base_score = colony_base_value + bld_tally + tech_tally + enemy_val - cost_score
489    # If the AI does have enough total military to attack this target, and the target is more than minimally valuable,
490    # don't let the threat_factor discount the adjusted value below MIN_INVASION_SCORE +1, so that if there are no
491    # other targets the AI could still pursue this one.  Otherwise, scoring pressure from
492    # MilitaryAI.get_preferred_max_military_portion_for_single_battle might prevent the AI from attacking heavily
493    # defended but still defeatable targets even if it has no softer targets available.
494    if total_max_mil_rating > sys_total_threat and base_score > 2 * MIN_INVASION_SCORE:
495        threat_factor = max(threat_factor, (MIN_INVASION_SCORE + 1)/base_score)
496    planet_score = retaliation_risk_factor(planet.owner) * threat_factor * max(0, base_score)
497    if clear_path:
498        planet_score *= 1.5
499    if verbose:
500        debug(' - planet score: %.2f\n'
501              ' - planned troops: %.2f\n'
502              ' - projected troop cost: %.1f\n'
503              ' - threat factor: %s\n'
504              ' - planet detail: %s\n'
505              ' - popval: %.1f\n'
506              ' - bldval: %s\n'
507              ' - enemyval: %s',
508              planet_score, planned_troops, troop_cost, threat_factor, detail, colony_base_value, bld_tally, enemy_val)
509        debug(' - system secured: %s' % system_secured)
510    return [planet_score, planned_troops]
511
512
513def send_invasion_fleets(fleet_ids, evaluated_planets, mission_type):
514    """sends a list of invasion fleets to a list of planet_value_pairs"""
515    if not fleet_ids:
516        return
517
518    universe = fo.getUniverse()
519    invasion_fleet_pool = set(fleet_ids)
520
521    for planet_id, pscore, ptroops in evaluated_planets:
522        if pscore < MIN_INVASION_SCORE:
523            continue
524        planet = universe.getPlanet(planet_id)
525        if not planet:
526            continue
527        sys_id = planet.systemID
528        found_fleets = []
529        found_stats = {}
530        min_stats = {'rating': 0, 'troopCapacity': ptroops}
531        target_stats = {'rating': 10,
532                        'troopCapacity': ptroops + _TROOPS_SAFETY_MARGIN,
533                        'target_system': TargetSystem(sys_id)}
534        these_fleets = FleetUtilsAI.get_fleets_for_mission(target_stats, min_stats, found_stats,
535                                                           starting_system=sys_id, fleet_pool_set=invasion_fleet_pool,
536                                                           fleet_list=found_fleets)
537        if not these_fleets:
538            if not FleetUtilsAI.stats_meet_reqs(found_stats, min_stats):
539                debug("Insufficient invasion troop allocation for system %d ( %s ) -- requested %s , found %s" % (
540                    sys_id, universe.getSystem(sys_id).name, min_stats, found_stats))
541                invasion_fleet_pool.update(found_fleets)
542                continue
543            else:
544                these_fleets = found_fleets
545        target = TargetPlanet(planet_id)
546        debug("assigning invasion fleets %s to target %s" % (these_fleets, target))
547        aistate = get_aistate()
548        for fleetID in these_fleets:
549            fleet_mission = aistate.get_fleet_mission(fleetID)
550            fleet_mission.clear_fleet_orders()
551            fleet_mission.clear_target()
552            fleet_mission.set_target(mission_type, target)
553
554
555def assign_invasion_bases():
556    """Assign our troop bases to invasion targets."""
557    universe = fo.getUniverse()
558    all_troopbase_fleet_ids = FleetUtilsAI.get_empire_fleet_ids_by_role(MissionType.ORBITAL_INVASION)
559    available_troopbase_fleet_ids = set(FleetUtilsAI.extract_fleet_ids_without_mission_types(all_troopbase_fleet_ids))
560
561    aistate = get_aistate()
562    for fid in list(available_troopbase_fleet_ids):
563        if fid not in available_troopbase_fleet_ids:  # entry may have been discarded in previous loop iterations
564            continue
565        fleet = universe.getFleet(fid)
566        if not fleet:
567            continue
568        sys_id = fleet.systemID
569        system = universe.getSystem(sys_id)
570        available_planets = set(system.planetIDs).intersection(set(aistate.qualifyingTroopBaseTargets.keys()))
571        debug("Considering Base Troopers in %s, found planets %s and registered targets %s with status %s" % (
572            system.name, list(system.planetIDs), available_planets,
573            [(pid, aistate.qualifyingTroopBaseTargets[pid]) for pid in available_planets]))
574        targets = [pid for pid in available_planets if aistate.qualifyingTroopBaseTargets[pid][1] != -1]
575        if not targets:
576            debug("Failure: found no valid target for troop base in system %s" % system)
577            continue
578        status = aistate.systemStatus.get(sys_id, {})
579        local_base_troops = set(status.get('myfleets', [])).intersection(available_troopbase_fleet_ids)
580
581        target_id = INVALID_ID
582        best_score = -1
583        target_troops = 0
584        for pid, (p_score, p_troops) in assign_invasion_values(targets).items():
585            if p_score > best_score:
586                best_score = p_score
587                target_id = pid
588                target_troops = p_troops
589        if target_id == INVALID_ID:
590            continue
591        local_base_troops.discard(fid)
592        found_fleets = []
593        troops_needed = max(0, target_troops - FleetUtilsAI.count_troops_in_fleet(fid))
594        found_stats = {}
595        min_stats = {'rating': 0, 'troopCapacity': troops_needed}
596        target_stats = {'rating': 10, 'troopCapacity': troops_needed + _TROOPS_SAFETY_MARGIN}
597
598        FleetUtilsAI.get_fleets_for_mission(target_stats, min_stats, found_stats,
599                                            starting_system=sys_id, fleet_pool_set=local_base_troops,
600                                            fleet_list=found_fleets)
601        for fid2 in found_fleets:
602            FleetUtilsAI.merge_fleet_a_into_b(fid2, fid)
603            available_troopbase_fleet_ids.discard(fid2)
604        available_troopbase_fleet_ids.discard(fid)
605        aistate.qualifyingTroopBaseTargets[target_id][1] = -1  # TODO: should probably delete
606        target = TargetPlanet(target_id)
607        fleet_mission = aistate.get_fleet_mission(fid)
608        fleet_mission.set_target(MissionType.ORBITAL_INVASION, target)
609
610
611def assign_invasion_fleets_to_invade():
612    """Assign fleet targets to invadable planets."""
613    aistate = get_aistate()
614
615    assign_invasion_bases()
616
617    all_invasion_fleet_ids = FleetUtilsAI.get_empire_fleet_ids_by_role(MissionType.INVASION)
618    invasion_fleet_ids = FleetUtilsAI.extract_fleet_ids_without_mission_types(all_invasion_fleet_ids)
619    send_invasion_fleets(invasion_fleet_ids, AIstate.invasionTargets, MissionType.INVASION)
620    all_invasion_fleet_ids = FleetUtilsAI.get_empire_fleet_ids_by_role(MissionType.INVASION)
621    for fid in FleetUtilsAI.extract_fleet_ids_without_mission_types(all_invasion_fleet_ids):
622        this_mission = aistate.get_fleet_mission(fid)
623        this_mission.check_mergers(context="Post-send consolidation of unassigned troops")
624