File: ProductionAI.py

package info (click to toggle)
freeorion 0.5.1.2-1
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid
  • size: 194,920 kB
  • sloc: cpp: 186,821; python: 40,979; ansic: 1,164; xml: 721; makefile: 32; sh: 7
file content (1946 lines) | stat: -rw-r--r-- 97,595 bytes parent folder | download | duplicates (2)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
import freeOrionAIInterface as fo
import math
import random
from collections.abc import Iterable, Sequence
from logging import debug, error, warning
from operator import itemgetter
from typing import (
    NamedTuple,
    NewType,
    Union,
)

import AIDependencies
import AIstate
import FleetUtilsAI
import MilitaryAI
import PlanetUtilsAI
import PriorityAI
from AIDependencies import INVALID_ID, Tags
from aistate_interface import get_aistate
from buildings import BuildingType, BuildingTypeBase, Shipyard, get_empire_drydocks
from character.character_module import Aggression
from colonization import rate_planetary_piloting
from colonization.colony_score import MINIMUM_COLONY_SCORE
from colonization.rate_pilots import GREAT_PILOT_RATING
from common.fo_typing import BuildingName, PlanetId, SystemId
from empire.buildings_locations import get_best_pilot_facilities
from empire.colony_builders import (
    can_build_colony_for_species,
    can_build_only_sly_colonies,
    get_colony_builder_locations,
    get_colony_builders,
)
from empire.pilot_rating import (
    best_pilot_rating,
    get_pilot_ratings,
    get_rating_for_planet,
    medium_pilot_rating,
)
from empire.ship_builders import (
    can_build_ship_for_species,
    get_ship_builder_locations,
    has_shipyard,
)
from EnumsAI import (
    EmpireProductionTypes,
    FocusType,
    MissionType,
    PriorityType,
    ShipRoleType,
    get_priority_production_types,
)
from expansion_plans import get_colonisable_planet_ids
from freeorion_tools import get_named_real, ppstring, tech_is_complete
from production import print_building_list, print_capital_info, print_production_queue
from turn_state import (
    get_all_empire_planets,
    get_colonized_planets,
    get_colonized_planets_in_system,
    get_empire_outposts,
    get_empire_planets_by_species,
    get_empire_planets_with_species,
    get_inhabited_planets,
    get_owned_planets,
    get_owned_planets_in_system,
    population_with_industry_focus,
)
from turn_state.design import get_best_ship_info, get_best_ship_ratings
from universe.system_network import get_neighbors


def get_priority_locations() -> frozenset[PlanetId]:
    priority_facilities = [
        "BLD_SHIPYARD_ENRG_SOLAR",
        "BLD_SHIPYARD_CON_GEOINT",
        "BLD_SHIPYARD_AST_REF",
        Shipyard.ENRG_COMP.value,
    ]
    # TODO: also cover good troopship locations
    return frozenset(loc for building in priority_facilities for loc in get_best_pilot_facilities(building))


# set by ResourceAI
candidate_for_translator = None


def translators_wanted() -> int:
    """
    How many near universal translators we'd like to build.
    Function is also used by ResearchAI to determine when to research the prerequisites.
    """
    pid = PlanetUtilsAI.get_capital()
    # planet is needed to determine the cost. Without a capital we have bigger problems anyway...
    # At the beginning of the game influence_priority fluctuates strongly and that early in the game there are
    # surely more important things to build.
    if pid == INVALID_ID or fo.currentTurn() < 30:
        return 0.0
    building_type = BuildingType.TRANSLATOR
    translator_cost = building_type.production_cost(pid)
    influence_priority = get_aistate().get_priority(PriorityType.RESOURCE_INFLUENCE)
    num_species = len(get_empire_planets_by_species())
    num_enqueued = len(building_type.queued_at())
    num_built = len(building_type.built_at())
    # first one gives a policy slot
    first_bonus = 30 if num_enqueued + num_built == 0 else 0
    importance = 6 * (influence_priority + first_bonus) / translator_cost * num_species**0.25 - num_enqueued
    debug(
        f"translators_wanted: influence_priority: {influence_priority}, translator_cost: {translator_cost}, "
        f"built: {num_built}, enqueued: {num_enqueued}, num_species: {num_species}, turn: {fo.currentTurn()}, "
        f"importance: {importance}"
    )
    return int(importance)


def _get_capital_info() -> tuple[PlanetId, "fo.planet", SystemId]:
    capital_id = PlanetUtilsAI.get_capital()
    if capital_id is None or capital_id == INVALID_ID:
        homeworld = None
        capital_system_id = None
    else:
        homeworld = fo.getUniverse().getPlanet(capital_id)
        capital_system_id = homeworld.systemID
    return capital_id, homeworld, capital_system_id


def _first_turn_first_time():
    """
    Return true if generate production order at the first time.

    If game is loaded on the first turn this code should return false.
    """
    return fo.currentTurn() == 1 and len(AIstate.opponentPlanetIDs) == 0 and len(fo.getEmpire().productionQueue) == 0


def _first_turn_action():
    if not _first_turn_first_time():
        return

    init_build_nums = [(PriorityType.PRODUCTION_EXPLORATION, 2)]
    if can_build_only_sly_colonies():
        init_build_nums.append((PriorityType.PRODUCTION_COLONISATION, 1))
    else:
        init_build_nums.append((PriorityType.PRODUCTION_OUTPOST, 1))
    for ship_type, num_ships in init_build_nums:
        best_design_id, _, build_choices = get_best_ship_info(ship_type)
        if best_design_id is not None:
            for _ in range(num_ships):
                fo.issueEnqueueShipProductionOrder(best_design_id, build_choices[0])
            fo.updateProductionQueue()


def get_building_allocations() -> float:
    empire = fo.getEmpire()
    return sum(e.allocation for e in empire.productionQueue if e.buildType == EmpireProductionTypes.BT_BUILDING)


# TODO Move Building names to AIDependencies to avoid typos and for IDE-Support
def generate_production_orders():  # noqa: C901
    """Generate production orders."""
    # first check ship designs
    # next check for buildings etc, that could be placed on queue regardless of locally available PP
    # next loop over resource groups, adding buildings & ships
    print_production_queue()
    universe = fo.getUniverse()

    debug("Production Queue Management:")
    empire = fo.getEmpire()
    debug("")
    debug("  Total Available Production Points: %s" % empire.productionPoints)
    print_building_list()

    aistate = get_aistate()

    _first_turn_action()
    building_expense = 0.0
    building_ratio = aistate.character.preferred_building_ratio([0.4, 0.35, 0.30])
    capital_id, homeworld, capital_system_id = _get_capital_info()
    current_turn = fo.currentTurn()
    building_expense += get_building_allocations()
    if not homeworld:
        debug("if no capital, no place to build, should get around to capturing or colonizing a new one")  # TODO
    else:
        debug("Empire priority_id %d has current Capital %s:" % (empire.empireID, homeworld.name))
        print_capital_info(homeworld)
        capital_buildings = [universe.getBuilding(bldg).buildingTypeName for bldg in homeworld.buildingIDs]

        possible_building_type_ids = []
        for type_id in empire.availableBuildingTypes:
            # Apparently, when loading a saved game from another version, availableBuildingTypes may return
            # buildings that are not in our script.
            fo_building_type = fo.getBuildingType(type_id)
            if fo_building_type and fo_building_type.canBeProduced(empire.empireID, homeworld.id):
                possible_building_type_ids.append(type_id)

        if possible_building_type_ids:
            debug("Possible building types to build:")
            for type_id in possible_building_type_ids:
                building_type = fo.getBuildingType(type_id)
                debug(
                    "    {} cost: {}  time: {}".format(
                        building_type.name,
                        building_type.productionCost(empire.empireID, homeworld.id),
                        building_type.productionTime(empire.empireID, homeworld.id),
                    )
                )

            possible_building_types = [fo.getBuildingType(type_id).name for type_id in possible_building_type_ids]

            queued_building_names = _get_queued_buildings(capital_id)

            if "BLD_AUTO_HISTORY_ANALYSER" in possible_building_types:
                for pid in find_automatic_historic_analyzer_candidates():
                    res = fo.issueEnqueueBuildingProductionOrder("BLD_AUTO_HISTORY_ANALYSER", pid)
                    debug(
                        "Enqueueing BLD_AUTO_HISTORY_ANALYSER at planet %s - result %d" % (universe.getPlanet(pid), res)
                    )
                    if res:
                        cost, time = empire.productionCostAndTime(
                            empire.productionQueue[empire.productionQueue.size - 1]
                        )
                        building_expense += cost / time
                        res = fo.issueRequeueProductionOrder(empire.productionQueue.size - 1, 0)  # move to front
                        debug(
                            "Requeueing %s to front of build queue, with result %d" % ("BLD_AUTO_HISTORY_ANALYSER", res)
                        )

            # TODO: check existence of BLD_INDUSTRY_CENTER (and other buildings) in other locations in case we captured it
            if (
                (empire.productionPoints > 40 or ((current_turn > 40) and (population_with_industry_focus() >= 20)))
                and ("BLD_INDUSTRY_CENTER" in possible_building_types)
                and ("BLD_INDUSTRY_CENTER" not in (capital_buildings + queued_building_names))
                and (building_expense < building_ratio * empire.productionPoints)
            ):
                res = fo.issueEnqueueBuildingProductionOrder("BLD_INDUSTRY_CENTER", homeworld.id)
                debug("Enqueueing BLD_INDUSTRY_CENTER, with result %d" % res)
                if res:
                    cost, time = empire.productionCostAndTime(empire.productionQueue[empire.productionQueue.size - 1])
                    building_expense += cost / time

            if ("BLD_SHIPYARD_BASE" in possible_building_types) and (
                "BLD_SHIPYARD_BASE" not in (capital_buildings + queued_building_names)
            ):
                try:
                    res = fo.issueEnqueueBuildingProductionOrder("BLD_SHIPYARD_BASE", homeworld.id)
                    debug("Enqueueing BLD_SHIPYARD_BASE, with result %d" % res)
                except:  # noqa: E722
                    warning("Can't build shipyard at new capital, probably no population; we're hosed")

            for building_name in ["BLD_SHIPYARD_ORG_ORB_INC"]:
                if (
                    (building_name in possible_building_types)
                    and (building_name not in (capital_buildings + queued_building_names))
                    and (building_expense < building_ratio * empire.productionPoints)
                ):
                    try:
                        res = fo.issueEnqueueBuildingProductionOrder(building_name, homeworld.id)
                        debug("Enqueueing %s at capital, with result %d" % (building_name, res))
                        if res:
                            cost, time = empire.productionCostAndTime(
                                empire.productionQueue[empire.productionQueue.size - 1]
                            )
                            building_expense += cost / time
                            res = fo.issueRequeueProductionOrder(empire.productionQueue.size - 1, 0)  # move to front
                            debug("Requeueing %s to front of build queue, with result %d" % (building_name, res))
                    except:  # noqa: E722
                        error("Exception triggered and caught: ", exc_info=True)

            building_type = BuildingType.PALACE
            if building_type.available() and not building_type.built_or_queued_at():
                building_expense += _try_enqueue(building_type, capital_id, at_front=True, ignore_dislike=True)

            # ok, BLD_NEUTRONIUM_SYNTH is not currently unlockable, but just in case... ;-p
            if ("BLD_NEUTRONIUM_SYNTH" in possible_building_types) and (
                "BLD_NEUTRONIUM_SYNTH" not in (capital_buildings + queued_building_names)
            ):
                res = fo.issueEnqueueBuildingProductionOrder("BLD_NEUTRONIUM_SYNTH", homeworld.id)
                debug("Enqueueing BLD_NEUTRONIUM_SYNTH, with result %d" % res)
                if res:
                    res = fo.issueRequeueProductionOrder(empire.productionQueue.size - 1, 0)  # move to front
                    debug("Requeueing BLD_NEUTRONIUM_SYNTH to front of build queue, with result %d" % res)

    max_defense_portion = aistate.character.max_defense_portion()
    if aistate.character.check_orbital_production():
        sys_orbital_defenses = {}
        queued_defenses = {}
        defense_allocation = 0.0
        target_orbitals = aistate.character.target_number_of_orbitals()
        debug("Orbital Defense Check -- target Defense Orbitals: %s" % target_orbitals)
        for element in empire.productionQueue:
            if (element.buildType == EmpireProductionTypes.BT_SHIP) and (
                aistate.get_ship_role(element.designID) == ShipRoleType.BASE_DEFENSE
            ):
                planet = universe.getPlanet(element.locationID)
                if not planet:
                    error("Problem getting Planet for build loc %s" % element.locationID)
                    continue
                sys_id = planet.systemID
                queued_defenses[sys_id] = queued_defenses.get(sys_id, 0) + element.blocksize * element.remaining
                defense_allocation += element.allocation
        debug(
            "Queued Defenses: %s",
            ppstring([(str(universe.getSystem(sid)), num) for sid, num in queued_defenses.items()]),
        )
        for sys_id, pids in get_colonized_planets().items():
            if aistate.systemStatus.get(sys_id, {}).get("fleetThreat", 1) > 0:
                continue  # don't build orbital shields if enemy fleet present
            if defense_allocation > max_defense_portion * empire.productionPoints:
                break
            sys_orbital_defenses[sys_id] = 0
            fleets_here = aistate.systemStatus.get(sys_id, {}).get("myfleets", [])
            for fid in fleets_here:
                if aistate.get_fleet_role(fid) == MissionType.ORBITAL_DEFENSE:
                    debug(
                        "Found %d existing Orbital Defenses in %s :"
                        % (aistate.fleetStatus.get(fid, {}).get("nships", 0), universe.getSystem(sys_id))
                    )
                    sys_orbital_defenses[sys_id] += aistate.fleetStatus.get(fid, {}).get("nships", 0)
            for pid in pids:
                sys_orbital_defenses[sys_id] += queued_defenses.get(pid, 0)
            if sys_orbital_defenses[sys_id] < target_orbitals:
                num_needed = target_orbitals - sys_orbital_defenses[sys_id]
                for pid in pids:
                    best_design_id, col_design, build_choices = get_best_ship_info(
                        PriorityType.PRODUCTION_ORBITAL_DEFENSE, pid
                    )
                    if not best_design_id:
                        debug("no orbital defenses can be built at %s", PlanetUtilsAI.planet_string(pid))
                        continue
                    retval = fo.issueEnqueueShipProductionOrder(best_design_id, pid)
                    debug("queueing %d Orbital Defenses at %s" % (num_needed, PlanetUtilsAI.planet_string(pid)))
                    if retval != 0:
                        if num_needed > 1:
                            fo.issueChangeProductionQuantityOrder(empire.productionQueue.size - 1, 1, num_needed)
                        cost, time = empire.productionCostAndTime(
                            empire.productionQueue[empire.productionQueue.size - 1]
                        )
                        defense_allocation += (
                            empire.productionQueue[empire.productionQueue.size - 1].blocksize * cost / time
                        )
                        fo.issueRequeueProductionOrder(empire.productionQueue.size - 1, 0)  # move to front
                        break

    info = _build_basic_shipyards()
    queued_shipyard_pids = info.queued_shipyard_pids
    colony_systems = info.colony_systems
    top_pilot_systems = info.top_pilot_systems
    blackhole_pilots, red_pilots, building_expense = _build_energy_shipyards(
        queued_shipyard_pids, colony_systems, building_ratio, building_expense
    )
    _build_ship_facilities(Shipyard.ORG_ORB_INC)
    # gating by life cycle manipulation helps delay these until they are closer to being worthwhile
    if tech_is_complete(AIDependencies.GRO_LIFE_CYCLE) or empire.researchProgress(AIDependencies.GRO_LIFE_CYCLE) > 0:
        for building_type in (Shipyard.XENO_FACILITY, Shipyard.ORG_CELL_GRO_CHAMB):
            _build_ship_facilities(building_type)
    building_expense += _build_asteroid_processor(top_pilot_systems, queued_shipyard_pids)

    building_expense += _build_gas_giant_generator()
    building_expense += _build_translator()
    building_expense += _build_regional_administration()
    building_expense += _build_military_command()

    building_name = "BLD_SOL_ORB_GEN"
    if empire.buildingTypeAvailable(building_name) and aistate.character.may_build_building(building_name):
        already_got_one = 99
        for pid in get_all_empire_planets():
            planet = universe.getPlanet(pid)
            if planet and building_name in [
                bld.buildingTypeName for bld in map(universe.getBuilding, planet.buildingIDs)
            ]:
                system = universe.getSystem(planet.systemID)
                if system and system.starType < already_got_one:
                    already_got_one = system.starType
        best_type = fo.starType.white
        best_locs = AIstate.empireStars.get(fo.starType.blue, []) + AIstate.empireStars.get(fo.starType.white, [])
        if not best_locs:
            best_type = fo.starType.orange
            best_locs = AIstate.empireStars.get(fo.starType.yellow, []) + AIstate.empireStars.get(
                fo.starType.orange, []
            )
        if (not best_locs) or (already_got_one < 99 and already_got_one <= best_type):
            pass  # could consider building at a red star if have a lot of PP but somehow no better stars
        else:
            use_new_loc = True
            queued_building_locs = [
                element.locationID for element in (empire.productionQueue) if (element.name == building_name)
            ]
            if queued_building_locs:
                queued_star_types = {}
                for loc in queued_building_locs:
                    planet = universe.getPlanet(loc)
                    if not planet:
                        continue
                    system = universe.getSystem(planet.systemID)
                    queued_star_types.setdefault(system.starType, []).append(loc)
                if queued_star_types:
                    best_queued = sorted(queued_star_types.keys())[0]
                    if best_queued > best_type:  # i.e., best_queued is yellow, best_type available is blue or white
                        pass  # should probably evaluate cancelling the existing one under construction
                    else:
                        use_new_loc = False
            if use_new_loc:  # (of course, may be only loc, not really new)
                if not homeworld:
                    use_sys = best_locs[0]  # as good as any
                else:
                    distance_map = {}
                    for sys_id in best_locs:  # want to build close to capital for defense
                        if sys_id == INVALID_ID:
                            continue
                        try:
                            distance_map[sys_id] = universe.jumpDistance(homeworld.systemID, sys_id)
                        except:  # noqa: E722
                            pass
                    use_sys = ([(-1, INVALID_ID)] + sorted([(dist, sys_id) for sys_id, dist in distance_map.items()]))[
                        :2
                    ][-1][
                        -1
                    ]  # kinda messy, but ensures a value
                if use_sys != INVALID_ID:
                    try:
                        use_loc = get_owned_planets_in_system(use_sys)[0]
                        res = fo.issueEnqueueBuildingProductionOrder(building_name, use_loc)
                        debug(
                            "Enqueueing %s at planet %d (%s) , with result %d",
                            building_name,
                            use_loc,
                            universe.getPlanet(use_loc).name,
                            res,
                        )
                        if res:
                            cost, time = empire.productionCostAndTime(
                                empire.productionQueue[empire.productionQueue.size - 1]
                            )
                            building_expense += cost / time  # production_queue[production_queue.size -1].blocksize *
                            res = fo.issueRequeueProductionOrder(empire.productionQueue.size - 1, 0)  # move to front
                            debug("Requeueing %s to front of build queue, with result %d", building_name, res)
                    except:  # noqa: E722
                        debug("problem queueing BLD_SOL_ORB_GEN at planet %s of system", use_loc, use_sys)

    building_name = "BLD_ART_BLACK_HOLE"
    if (
        empire.buildingTypeAvailable(building_name)
        and aistate.character.may_build_building(building_name)
        and len(AIstate.empireStars.get(fo.starType.red, [])) > 0
    ):
        already_got_one = False
        for pid in get_all_empire_planets():
            planet = universe.getPlanet(pid)
            if planet and building_name in [
                bld.buildingTypeName for bld in map(universe.getBuilding, planet.buildingIDs)
            ]:
                already_got_one = True  # has been built, needs one turn to activate
        queued_building_locs = [
            element.locationID for element in (empire.productionQueue) if (element.name == building_name)
        ]  # TODO: check that queued locs or already built one are at red stars
        if not blackhole_pilots and len(queued_building_locs) == 0 and (red_pilots or not already_got_one):
            use_loc = None
            nominal_home = homeworld or universe.getPlanet(
                (red_pilots + get_owned_planets_in_system(AIstate.empireStars[fo.starType.red][0]))[0]
            )
            distance_map = {}
            for sys_id in AIstate.empireStars.get(fo.starType.red, []):
                if sys_id == INVALID_ID:
                    continue
                try:
                    distance_map[sys_id] = universe.jumpDistance(nominal_home.systemID, sys_id)
                except:  # noqa: E722
                    pass
            red_sys_list = sorted([(dist, sys_id) for sys_id, dist in distance_map.items()])
            for dist, sys_id in red_sys_list:
                for loc in get_owned_planets_in_system(sys_id):
                    planet = universe.getPlanet(loc)
                    if planet and planet.speciesName not in ["", None]:
                        species = fo.getSpecies(planet.speciesName)
                        if species and "PHOTOTROPHIC" in list(species.tags):
                            break
                else:
                    use_loc = list(
                        set(red_pilots).intersection(get_owned_planets_in_system(sys_id))
                        or get_owned_planets_in_system(sys_id)
                    )[0]
                if use_loc is not None:
                    break
            if use_loc is not None:
                planet_used = universe.getPlanet(use_loc)
                try:
                    res = fo.issueEnqueueBuildingProductionOrder(building_name, use_loc)
                    debug("Enqueueing %s at planet %s , with result %d", building_name, planet_used, res)
                    if res:
                        res = fo.issueRequeueProductionOrder(empire.productionQueue.size - 1, 0)  # move to front
                        debug("Requeueing %s to front of build queue, with result %d", building_name, res)
                except:  # noqa: E722
                    debug(f"problem queueing {building_name} at planet {planet_used}")

    building_name = "BLD_BLACK_HOLE_POW_GEN"
    if empire.buildingTypeAvailable(building_name) and aistate.character.may_build_building(building_name):
        already_got_one = False
        for pid in get_all_empire_planets():
            planet = universe.getPlanet(pid)
            if planet and building_name in [
                bld.buildingTypeName for bld in map(universe.getBuilding, planet.buildingIDs)
            ]:
                already_got_one = True
        queued_building_locs = [
            element.locationID for element in (empire.productionQueue) if (element.name == building_name)
        ]
        if (
            (len(AIstate.empireStars.get(fo.starType.blackHole, [])) > 0)
            and len(queued_building_locs) == 0
            and not already_got_one
        ):
            if not homeworld:
                use_sys = AIstate.empireStars.get(fo.starType.blackHole, [])[0]
            else:
                distance_map = {}
                for sys_id in AIstate.empireStars.get(fo.starType.blackHole, []):
                    if sys_id == INVALID_ID:
                        continue
                    try:
                        distance_map[sys_id] = universe.jumpDistance(homeworld.systemID, sys_id)
                    except:  # noqa: E722
                        pass
                use_sys = ([(-1, INVALID_ID)] + sorted([(dist, sys_id) for sys_id, dist in distance_map.items()]))[:2][
                    -1
                ][
                    -1
                ]  # kinda messy, but ensures a value
            if use_sys != INVALID_ID:
                try:
                    use_loc = get_owned_planets_in_system(use_sys)[0]
                    res = fo.issueEnqueueBuildingProductionOrder(building_name, use_loc)
                    debug(
                        "Enqueueing %s at planet %d (%s) , with result %d",
                        building_name,
                        use_loc,
                        universe.getPlanet(use_loc).name,
                        res,
                    )
                    if res:
                        res = fo.issueRequeueProductionOrder(empire.productionQueue.size - 1, 0)  # move to front
                        debug("Requeueing %s to front of build queue, with result %d" % (building_name, res))
                except:  # noqa: E722
                    warning("problem queueing BLD_BLACK_HOLE_POW_GEN at planet %s of system %s", use_loc, use_sys)

    building_name = "BLD_ENCLAVE_VOID"
    if empire.buildingTypeAvailable(building_name):
        already_got_one = False
        for pid in get_all_empire_planets():
            planet = universe.getPlanet(pid)
            if planet and building_name in [
                bld.buildingTypeName for bld in map(universe.getBuilding, planet.buildingIDs)
            ]:
                already_got_one = True
        queued_locs = [element.locationID for element in (empire.productionQueue) if (element.name == building_name)]
        if len(queued_locs) == 0 and homeworld and not already_got_one:
            try:
                res = fo.issueEnqueueBuildingProductionOrder(building_name, capital_id)
                debug(
                    "Enqueueing %s at planet %d (%s) , with result %d",
                    building_name,
                    capital_id,
                    universe.getPlanet(capital_id).name,
                    res,
                )
                if res:
                    res = fo.issueRequeueProductionOrder(empire.productionQueue.size - 1, 0)  # move to front
                    debug("Requeueing %s to front of build queue, with result %d", building_name, res)
            except:  # noqa: E722
                pass

    building_name = "BLD_GENOME_BANK"
    if empire.buildingTypeAvailable(building_name):
        already_got_one = False
        for pid in get_all_empire_planets():
            planet = universe.getPlanet(pid)
            if planet and building_name in [
                bld.buildingTypeName for bld in map(universe.getBuilding, planet.buildingIDs)
            ]:
                already_got_one = True
        queued_locs = [element.locationID for element in (empire.productionQueue) if (element.name == building_name)]
        if len(queued_locs) == 0 and homeworld and not already_got_one:
            try:
                res = fo.issueEnqueueBuildingProductionOrder(building_name, capital_id)
                debug(
                    "Enqueueing %s at planet %d (%s) , with result %d",
                    building_name,
                    capital_id,
                    universe.getPlanet(capital_id).name,
                    res,
                )
                if res:
                    res = fo.issueRequeueProductionOrder(empire.productionQueue.size - 1, 0)  # move to front
                    debug("Requeueing %s to front of build queue, with result %d", building_name, res)
            except:  # noqa: E722
                pass

    building_name = "BLD_NEUTRONIUM_EXTRACTOR"
    already_got_extractor = False
    if (
        empire.buildingTypeAvailable(building_name)
        and [element.locationID for element in (empire.productionQueue) if (element.name == building_name)] == []
        and AIstate.empireStars.get(fo.starType.neutron, [])
    ):
        # building_type = fo.getBuildingType(building_name)
        for pid in get_all_empire_planets():
            planet = universe.getPlanet(pid)
            if planet:
                building_names = [bld.buildingTypeName for bld in map(universe.getBuilding, planet.buildingIDs)]
                if (
                    planet.systemID in AIstate.empireStars.get(fo.starType.neutron, [])
                    and building_name in building_names
                ) or "BLD_NEUTRONIUM_SYNTH" in building_names:
                    already_got_extractor = True
        if not already_got_extractor:
            if not homeworld:
                use_sys = AIstate.empireStars.get(fo.starType.neutron, [])[0]
            else:
                distance_map = {}
                for sys_id in AIstate.empireStars.get(fo.starType.neutron, []):
                    if sys_id == INVALID_ID:
                        continue
                    try:
                        distance_map[sys_id] = universe.jumpDistance(homeworld.systemID, sys_id)
                    except Exception:
                        warning("Could not get jump distance from %d to %d", homeworld.systemID, sys_id, exc_info=True)
                debug([INVALID_ID] + sorted([(dist, sys_id) for sys_id, dist in distance_map.items()]))
                use_sys = ([(-1, INVALID_ID)] + sorted([(dist, sys_id) for sys_id, dist in distance_map.items()]))[:2][
                    -1
                ][
                    -1
                ]  # kinda messy, but ensures a value
            if use_sys != INVALID_ID:
                try:
                    use_loc = get_owned_planets_in_system(use_sys)[0]
                    res = fo.issueEnqueueBuildingProductionOrder(building_name, use_loc)
                    debug(
                        "Enqueueing %s at planet %d (%s) , with result %d",
                        building_name,
                        use_loc,
                        universe.getPlanet(use_loc).name,
                        res,
                    )
                    if res:
                        res = fo.issueRequeueProductionOrder(empire.productionQueue.size - 1, 0)  # move to front
                        debug("Requeueing %s to front of build queue, with result %d", building_name, res)
                except:  # noqa: E722
                    warning(f"problem queueing BLD_NEUTRONIUM_EXTRACTOR at planet {use_loc} of system {use_sys}")

    _build_ship_facilities(Shipyard.GEO)

    # with current stats the AI considers Titanic Hull superior to Scattered Asteroid, so don't bother building for now
    # TODO: uncomment once dynamic assessment of prospective designs is enabled & indicates building is worthwhile
    _build_ship_facilities(Shipyard.ASTEROID_REF)

    _build_ship_facilities(Shipyard.NEUTRONIUM_FORGE, get_priority_locations())

    colony_ship_map = {}
    for fid in FleetUtilsAI.get_empire_fleet_ids_by_role(MissionType.COLONISATION):
        fleet = universe.getFleet(fid)
        if not fleet:
            continue
        for shipID in fleet.shipIDs:
            ship = universe.getShip(shipID)
            if ship and (aistate.get_ship_role(ship.design.id) == ShipRoleType.CIVILIAN_COLONISATION):
                colony_ship_map.setdefault(ship.speciesName, []).append(1)

    building_name = "BLD_CONC_CAMP"
    building_type = fo.getBuildingType(building_name)
    for pid in get_inhabited_planets():
        planet = universe.getPlanet(pid)
        if not planet:
            continue
        can_build_camp = building_type.canBeProduced(empire.empireID, pid) and empire.buildingTypeAvailable(
            building_name
        )
        t_pop = planet.initialMeterValue(fo.meterType.targetPopulation)
        c_pop = planet.initialMeterValue(fo.meterType.population)
        t_ind = planet.currentMeterValue(fo.meterType.targetIndustry)
        c_ind = planet.currentMeterValue(fo.meterType.industry)
        pop_disqualified = (c_pop <= 32) or (c_pop < 0.9 * t_pop)
        this_spec = planet.speciesName
        safety_margin_met = (
            can_build_colony_for_species(this_spec)
            and (len(get_empire_planets_with_species(this_spec)) + len(colony_ship_map.get(this_spec, [])) >= 2)
        ) or (c_pop >= 50)
        if (
            pop_disqualified or not safety_margin_met
        ):  # check even if not aggressive, etc, just in case acquired planet with a ConcCamp on it
            for bldg in planet.buildingIDs:
                if universe.getBuilding(bldg).buildingTypeName == building_name:
                    res = fo.issueScrapOrder(bldg)
                    debug("Tried scrapping %s at planet %s, got result %d", building_name, planet.name, res)
        elif aistate.character.may_build_building(building_name) and can_build_camp and (t_pop >= 36):
            if (
                (planet.focus == FocusType.FOCUS_GROWTH)
                or (AIDependencies.COMPUTRONIUM_SPECIAL in planet.specials)
                or (pid == capital_id)
            ):
                continue
                # now that focus setting takes these into account, probably works ok to have conc camp, but let's not push it
            queued_building_locs = [
                element.locationID for element in (empire.productionQueue) if (element.name == building_name)
            ]
            if c_pop >= 0.95 * t_pop:
                if pid not in queued_building_locs:
                    if planet.focus in [FocusType.FOCUS_INDUSTRY]:
                        if c_ind >= t_ind + c_pop:
                            continue
                    else:
                        old_focus = planet.focus
                        fo.issueChangeFocusOrder(pid, FocusType.FOCUS_INDUSTRY)
                        universe.updateMeterEstimates([pid])
                        t_ind = planet.currentMeterValue(fo.meterType.targetIndustry)
                        if c_ind >= t_ind + c_pop:
                            fo.issueChangeFocusOrder(pid, old_focus)
                            universe.updateMeterEstimates([pid])
                            continue
                    res = fo.issueEnqueueBuildingProductionOrder(building_name, pid)
                    debug(
                        "Enqueueing %s at planet %d (%s) , with result %d",
                        building_name,
                        pid,
                        universe.getPlanet(pid).name,
                        res,
                    )
                    if res:
                        queued_building_locs.append(pid)
                        fo.issueRequeueProductionOrder(empire.productionQueue.size - 1, 0)  # move to front
                    else:
                        # TODO: enable location condition reporting a la mapwnd BuildDesignatorWnd
                        warning(
                            f"Enqueing Conc Camp at {planet} despite building_type.canBeProduced(empire.empireID, pid) reporting {can_build_camp}"
                        )
    building_expense += _build_scanning_facility()

    _build_orbital_drydock(top_pilot_systems)

    building_name = "BLD_XENORESURRECTION_LAB"
    queued_xeno_lab_locs = [element.locationID for element in (empire.productionQueue) if element.name == building_name]
    for pid in get_all_empire_planets():
        if pid in queued_xeno_lab_locs or not empire.canBuild(fo.buildType.BT_BUILDING, building_name, pid):
            continue
        res = fo.issueEnqueueBuildingProductionOrder(building_name, pid)
        debug("Enqueueing %s at planet %d (%s) , with result %d", building_name, pid, planet.name, res)
        if res:
            res = fo.issueRequeueProductionOrder(empire.productionQueue.size - 1, 2)  # move to near front
            debug("Requeueing %s to front of build queue, with result %d", building_name, res)
            break
        else:
            warning("Failed enqueueing %s at planet %s, got result %d", building_name, planet, res)

    # ignore acquired-under-construction colony buildings for which our empire lacks the species
    queued_clny_bld_locs = [
        element.locationID
        for element in (empire.productionQueue)
        if (element.name.startswith("BLD_COL_") and empire_has_colony_bld_species(element.name))
    ]
    colony_bldg_entries = [
        entry
        for entry in get_colonisable_planet_ids().items()
        if entry[1][0] > MINIMUM_COLONY_SCORE
        and entry[0] not in queued_clny_bld_locs
        and entry[0] in get_empire_outposts()
        and not already_has_completed_colony_building(entry[0])
    ]
    colony_bldg_entries = colony_bldg_entries[: PriorityAI.allottedColonyTargets + 2]
    for entry in colony_bldg_entries:
        pid = entry[0]
        building_name = "BLD_COL_" + entry[1][1][3:]
        planet = universe.getPlanet(pid)
        building_type = fo.getBuildingType(building_name)
        # We may have conquered a planet with a queued colony.
        # If we want to build another species, we have to remove the queued one.
        _remove_other_colonies(pid, building_name)
        if not (building_type and building_type.canBeEnqueued(empire.empireID, pid)):
            continue
        res = fo.issueEnqueueBuildingProductionOrder(building_name, pid)
        debug("Enqueueing %s at planet %d (%s) , with result %d", building_name, pid, planet.name, res)
        if res:
            res = fo.issueRequeueProductionOrder(empire.productionQueue.size - 1, 2)  # move to near front
            debug("Requeueing %s to front of build queue, with result %d", building_name, res)
            break
        else:
            warning("Failed enqueueing %s at planet %s, got result %d" % (building_name, planet, res))

    buildings_to_scrap = ("BLD_EVACUATION", "BLD_GATEWAY_VOID")
    for pid in get_inhabited_planets():
        planet = universe.getPlanet(pid)
        if not planet:
            continue
        for bldg in planet.buildingIDs:
            building_name = universe.getBuilding(bldg).buildingTypeName
            if building_name in buildings_to_scrap:
                res = fo.issueScrapOrder(bldg)
                debug("Tried scrapping %s at planet %s, got result %d", building_name, planet.name, res)

    total_pp_spent = fo.getEmpire().productionQueue.totalSpent
    debug("  Total Production Points Spent: %s", total_pp_spent)

    wasted_pp = max(0, empire.productionPoints - total_pp_spent)
    debug("  Wasted Production Points: %s", wasted_pp)  # TODO: add resource group analysis
    avail_pp = empire.productionPoints - total_pp_spent - 0.0001

    production_queue = empire.productionQueue
    queued_colony_ships = {}
    queued_outpost_ships = 0
    queued_troop_ships = 0

    # TODO: blocked items might not need dequeuing, but rather for supply lines to be un-blockaded
    dequeue_list = []
    fo.updateProductionQueue()
    can_prioritize_troops = False
    for queue_index in range(len(production_queue)):
        element = production_queue[queue_index]
        block_str = (
            "%d x " % element.blocksize
        )  # ["a single ", "in blocks of %d "%element.blocksize][element.blocksize>1]
        debug(
            "    %s%s  requiring %s  more turns; alloc: %.2f PP with cum. progress of %.1f  being built at %s",
            block_str,
            element.name,
            element.turnsLeft,
            element.allocation,
            element.progress,
            universe.getPlanet(element.locationID).name,
        )
        if element.turnsLeft == -1:
            if element.locationID not in get_all_empire_planets():
                # dequeue_list.append(queue_index) #TODO add assessment of recapture -- invasion target etc.
                debug(
                    "element %s will never be completed as stands and location %d no longer owned; could consider deleting from queue",
                    element.name,
                    element.locationID,
                )  # TODO:
            else:
                debug(
                    "element %s is projected to never be completed as currently stands, but will remain on queue ",
                    element.name,
                )
        elif element.buildType == EmpireProductionTypes.BT_SHIP:
            this_role = aistate.get_ship_role(element.designID)
            if this_role == ShipRoleType.CIVILIAN_COLONISATION:
                this_spec = universe.getPlanet(element.locationID).speciesName
                queued_colony_ships[this_spec] = (
                    queued_colony_ships.get(this_spec, 0) + element.remaining * element.blocksize
                )
            elif this_role == ShipRoleType.CIVILIAN_OUTPOST:
                queued_outpost_ships += element.remaining * element.blocksize
            elif this_role == ShipRoleType.BASE_OUTPOST:
                queued_outpost_ships += element.remaining * element.blocksize
            elif this_role == ShipRoleType.MILITARY_INVASION:
                queued_troop_ships += element.remaining * element.blocksize
            elif (this_role == ShipRoleType.CIVILIAN_EXPLORATION) and (queue_index <= 1):
                if len(AIstate.opponentPlanetIDs) > 0:
                    can_prioritize_troops = True
    if queued_colony_ships:
        debug("\nFound colony ships in build queue: %s", queued_colony_ships)
    if queued_outpost_ships:
        debug("\nFound outpost ships and bases in build queue: %s", queued_outpost_ships)

    for queue_index in dequeue_list[::-1]:
        fo.issueDequeueProductionOrder(queue_index)

    all_military_fleet_ids = FleetUtilsAI.get_empire_fleet_ids_by_role(MissionType.MILITARY)
    total_military_ships = sum([aistate.fleetStatus.get(fid, {}).get("nships", 0) for fid in all_military_fleet_ids])
    all_troop_fleet_ids = FleetUtilsAI.get_empire_fleet_ids_by_role(MissionType.INVASION)
    total_troop_ships = sum([aistate.fleetStatus.get(fid, {}).get("nships", 0) for fid in all_troop_fleet_ids])
    avail_troop_fleet_ids = list(FleetUtilsAI.extract_fleet_ids_without_mission_types(all_troop_fleet_ids))
    total_available_troops = sum([aistate.fleetStatus.get(fid, {}).get("nships", 0) for fid in avail_troop_fleet_ids])
    debug(
        "Trooper Status turn %d: %d total, with %d unassigned. %d queued, compared to %d total Military Attack Ships",
        current_turn,
        total_troop_ships,
        total_available_troops,
        queued_troop_ships,
        total_military_ships,
    )
    if (
        capital_id is not None
        and (current_turn >= 40 or can_prioritize_troops)
        and aistate.systemStatus.get(capital_system_id, {}).get("fleetThreat", 0) == 0
        and aistate.systemStatus.get(capital_system_id, {}).get("neighborThreat", 0) == 0
    ):
        best_design_id, best_design, build_choices = get_best_ship_info(PriorityType.PRODUCTION_INVASION)
        if build_choices is not None and len(build_choices) > 0:
            loc = random.choice(build_choices)
            prod_time = best_design.productionTime(empire.empireID, loc)
            prod_cost = best_design.productionCost(empire.empireID, loc)
            troopers_needed = max(
                0,
                int(
                    min(
                        0.99 + (current_turn / 20.0 - total_available_troops) / max(2, prod_time - 1),
                        total_military_ships // 3 - total_troop_ships,
                    )
                ),
            )
            ship_number = troopers_needed
            per_turn_cost = float(prod_cost) / prod_time
            if (
                troopers_needed > 0
                and empire.productionPoints > 3 * per_turn_cost * queued_troop_ships
                and aistate.character.may_produce_troops()
            ):
                retval = fo.issueEnqueueShipProductionOrder(best_design_id, loc)
                if retval != 0:
                    debug(
                        "forcing %d new ship(s) to production queue: %s; per turn production cost %.1f\n",
                        ship_number,
                        best_design.name,
                        ship_number * per_turn_cost,
                    )
                    if ship_number > 1:
                        fo.issueChangeProductionQuantityOrder(production_queue.size - 1, 1, ship_number)
                    avail_pp -= ship_number * per_turn_cost
                    fo.issueRequeueProductionOrder(production_queue.size - 1, 0)  # move to front
                    fo.updateProductionQueue()
        debug("")

    debug("")
    # get the highest production priorities
    production_priorities = {}
    for priority_type in get_priority_production_types():
        production_priorities[priority_type] = int(max(0, (aistate.get_priority(priority_type)) ** 0.5))

    sorted_priorities = sorted(production_priorities.items(), key=itemgetter(1), reverse=True)

    top_score = -1

    num_colony_fleets = len(
        FleetUtilsAI.get_empire_fleet_ids_by_role(MissionType.COLONISATION)
    )  # counting existing colony fleets each as one ship
    total_colony_fleets = sum(queued_colony_ships.values()) + num_colony_fleets
    num_outpost_fleets = len(
        FleetUtilsAI.get_empire_fleet_ids_by_role(MissionType.OUTPOST)
    )  # counting existing outpost fleets each as one ship
    total_outpost_fleets = queued_outpost_ships + num_outpost_fleets

    max_colony_fleets = PriorityAI.allottedColonyTargets
    max_outpost_fleets = max_colony_fleets

    _, _, colony_build_choices = get_best_ship_info(PriorityType.PRODUCTION_COLONISATION)
    military_emergency = PriorityAI.unmetThreat > (2.0 * MilitaryAI.get_tot_mil_rating())

    debug("Production Queue Priorities:")
    filtered_priorities = {}
    for priority_id, score in sorted_priorities:
        if military_emergency:
            if priority_id == PriorityType.PRODUCTION_EXPLORATION:
                score /= 10.0
            elif priority_id != PriorityType.PRODUCTION_MILITARY:
                score /= 2.0
        if top_score < score:
            top_score = score  # don't really need top_score nor sorting with current handling
        debug(" Score: %4d -- %s ", score, priority_id)
        if priority_id != PriorityType.PRODUCTION_BUILDINGS:
            if (
                (priority_id == PriorityType.PRODUCTION_COLONISATION)
                and (total_colony_fleets < max_colony_fleets)
                and (colony_build_choices is not None)
                and len(colony_build_choices) > 0
            ):
                filtered_priorities[priority_id] = score
            elif (priority_id == PriorityType.PRODUCTION_OUTPOST) and (total_outpost_fleets < max_outpost_fleets):
                filtered_priorities[priority_id] = score
            elif priority_id not in [PriorityType.PRODUCTION_OUTPOST, PriorityType.PRODUCTION_COLONISATION]:
                filtered_priorities[priority_id] = score
    if filtered_priorities == {}:
        debug("No non-building-production priorities with nonzero score, setting to default: Military")
        filtered_priorities[PriorityType.PRODUCTION_MILITARY] = 1
    if top_score <= 100:
        scaling_power = 1.0
    else:
        scaling_power = math.log(100) / math.log(top_score)
    for pty in filtered_priorities:
        filtered_priorities[pty] **= scaling_power

    available_pp = {
        tuple(el.key()): el.data() for el in empire.planetsWithAvailablePP
    }  # keys are sets of ints; data is doubles
    allocated_pp = {
        tuple(el.key()): el.data() for el in empire.planetsWithAllocatedPP
    }  # keys are sets of ints; data is doubles
    planets_with_wasted_pp = {tuple(pidset) for pidset in empire.planetsWithWastedPP}
    debug("avail_pp ( <systems> : pp ):")
    for planet_set in available_pp:
        debug(
            "\t%s\t%.2f",
            PlanetUtilsAI.sys_name_ids(set(PlanetUtilsAI.get_systems(planet_set))),
            available_pp[planet_set],
        )
    debug("\nallocated_pp ( <systems> : pp ):")
    for planet_set in allocated_pp:
        debug(
            "\t%s\t%.2f",
            PlanetUtilsAI.sys_name_ids(set(PlanetUtilsAI.get_systems(planet_set))),
            allocated_pp[planet_set],
        )

    debug("\n\nBuilding Ships in system groups with remaining PP:")
    for planet_set in planets_with_wasted_pp:
        total_pp = available_pp.get(planet_set, 0)
        avail_pp = total_pp - allocated_pp.get(planet_set, 0)
        if avail_pp <= 0.01:
            continue
        debug(
            "%.2f PP remaining in system group: %s",
            avail_pp,
            PlanetUtilsAI.sys_name_ids(set(PlanetUtilsAI.get_systems(planet_set))),
        )
        debug("\t owned planets in this group are:")
        debug("\t %s", PlanetUtilsAI.planet_string(planet_set))
        best_design_id, best_design, build_choices = get_best_ship_info(
            PriorityType.PRODUCTION_COLONISATION, planet_set
        )
        species_map = {}
        for loc in build_choices or []:
            this_spec = universe.getPlanet(loc).speciesName
            species_map.setdefault(this_spec, []).append(loc)
        colony_build_choices = []
        for pid, (score, this_spec) in aistate.colonisablePlanetIDs.items():
            # add planets multiple times to emulate choice with weight
            weight = int(math.ceil(score))
            planets_for_colonization = [pid_ for pid_ in species_map.get(this_spec, []) if pid_ in planet_set]
            weighted_planets = weight * planets_for_colonization
            colony_build_choices.extend(weighted_planets)

        local_priorities = {}
        local_priorities.update(filtered_priorities)
        best_ships = {}
        mil_build_choices = get_best_ship_ratings(planet_set)
        for priority in list(local_priorities):
            if priority == PriorityType.PRODUCTION_MILITARY:
                if not mil_build_choices:
                    del local_priorities[priority]
                    continue
                _, pid, best_design_id, best_design = mil_build_choices[0]
                build_choices = [pid]
                # score = ColonisationAI.pilotRatings.get(pid, 0)
                # if bestScore < ColonisationAI.curMidPilotRating:
            else:
                best_design_id, best_design, build_choices = get_best_ship_info(priority, planet_set)
            if best_design is None:
                del local_priorities[priority]  # must be missing a shipyard -- TODO build a shipyard if necessary
                continue
            best_ships[priority] = [best_design_id, best_design, build_choices]
            debug("best_ships[%s] = %s \t locs are %s from %s", priority, best_design.name, build_choices, planet_set)

        if len(local_priorities) == 0:
            debug(
                "Alert!! need shipyards in systemSet %s",
                PlanetUtilsAI.sys_name_ids(set(PlanetUtilsAI.get_systems(planet_set))),
            )
        priority_choices = []
        for priority in local_priorities:
            priority_choices.extend(int(local_priorities[priority]) * [priority])

        loop_count = 0
        while (
            (avail_pp > 0) and (loop_count < max(100, current_turn)) and (priority_choices != [])
        ):  # make sure don't get stuck in some nonbreaking loop like if all shipyards captured
            loop_count += 1
            debug("Beginning build enqueue loop %d; %.1f PP available", loop_count, avail_pp)
            this_priority = random.choice(priority_choices)
            debug("selected priority: %s", this_priority)
            making_colony_ship = False
            making_outpost_ship = False
            if this_priority == PriorityType.PRODUCTION_COLONISATION:
                if total_colony_fleets >= max_colony_fleets:
                    debug("Already sufficient colony ships in queue, trying next priority choice\n")
                    for i in range(len(priority_choices) - 1, -1, -1):
                        if priority_choices[i] == PriorityType.PRODUCTION_COLONISATION:
                            del priority_choices[i]
                    continue
                elif colony_build_choices is None or len(colony_build_choices) == 0:
                    for i in range(len(priority_choices) - 1, -1, -1):
                        if priority_choices[i] == PriorityType.PRODUCTION_COLONISATION:
                            del priority_choices[i]
                    continue
                else:
                    making_colony_ship = True
            if this_priority == PriorityType.PRODUCTION_OUTPOST:
                if total_outpost_fleets >= max_outpost_fleets:
                    debug("Already sufficient outpost ships in queue, trying next priority choice\n")
                    for i in range(len(priority_choices) - 1, -1, -1):
                        if priority_choices[i] == PriorityType.PRODUCTION_OUTPOST:
                            del priority_choices[i]
                    continue
                else:
                    making_outpost_ship = True
            best_design_id, best_design, build_choices = best_ships[this_priority]
            if making_colony_ship:
                loc = random.choice(colony_build_choices)
                best_design_id, best_design, build_choices = get_best_ship_info(
                    PriorityType.PRODUCTION_COLONISATION, loc
                )
            elif this_priority == PriorityType.PRODUCTION_MILITARY:
                selector = random.random()
                choice = mil_build_choices[0]  # mil_build_choices can't be empty due to earlier check
                for choice in mil_build_choices:
                    if choice[0] >= selector:
                        break
                loc, best_design_id, best_design = choice[1:4]
                if best_design is None:
                    warning(
                        "problem with mil_build_choices;"
                        f" with selector ({selector}) chose loc ({loc}), "
                        f"best_design_id ({best_design_id}), best_design (None) "
                        f"from mil_build_choices: {mil_build_choices}"
                    )
                    continue
            else:
                loc = random.choice(build_choices)

            ship_number = 1
            per_turn_cost = float(best_design.productionCost(empire.empireID, loc)) / best_design.productionTime(
                empire.empireID, loc
            )
            if this_priority == PriorityType.PRODUCTION_MILITARY:
                this_rating = get_rating_for_planet(pid)
                rating_ratio = float(this_rating) / best_pilot_rating()
                if rating_ratio < 0.1:
                    loc_planet = universe.getPlanet(loc)
                    if loc_planet:
                        pname = loc_planet.name
                        this_rating = rate_planetary_piloting(loc)
                        rating_ratio = float(this_rating) / best_pilot_rating()
                        qualifier = "suboptimal " if rating_ratio < 1.0 else ""
                        debug(
                            "Building mil ship at loc %d (%s) with %spilot Rating: %.1f; ratio to empire best is %.1f",
                            loc,
                            pname,
                            qualifier,
                            this_rating,
                            rating_ratio,
                        )
                while total_pp > 40 * per_turn_cost:
                    ship_number *= 2
                    per_turn_cost *= 2
            retval = fo.issueEnqueueShipProductionOrder(best_design_id, loc)
            if retval != 0:
                prioritized = False
                debug(
                    "adding %d new ship(s) at location %s to production queue: %s; per turn production cost %.1f\n",
                    ship_number,
                    PlanetUtilsAI.planet_string(loc),
                    best_design.name,
                    per_turn_cost,
                )
                if ship_number > 1:
                    fo.issueChangeProductionQuantityOrder(production_queue.size - 1, 1, ship_number)
                avail_pp -= per_turn_cost
                if making_colony_ship:
                    total_colony_fleets += ship_number
                    if total_pp > 4 * per_turn_cost:
                        fo.issueRequeueProductionOrder(production_queue.size - 1, 0)  # move to front
                    continue
                if making_outpost_ship:
                    total_outpost_fleets += ship_number
                    if total_pp > 4 * per_turn_cost:
                        fo.issueRequeueProductionOrder(production_queue.size - 1, 0)  # move to front
                    continue
                if total_pp > 10 * per_turn_cost:
                    leading_block_pp = 0
                    for elem in [production_queue[elemi] for elemi in range(0, min(4, production_queue.size))]:
                        cost, time = empire.productionCostAndTime(elem)
                        leading_block_pp += elem.blocksize * cost / time
                    if leading_block_pp > 0.5 * total_pp or (
                        military_emergency and this_priority == PriorityType.PRODUCTION_MILITARY
                    ):
                        prioritized = True
                        fo.issueRequeueProductionOrder(production_queue.size - 1, 0)  # move to front
                if this_priority == PriorityType.PRODUCTION_INVASION:
                    queued_troop_ships += ship_number
                    if not prioritized:
                        fo.issueRequeueProductionOrder(production_queue.size - 1, 0)  # move to front
        # The AI will normally only consider queuing additional ships (above) the current queue is not using all the
        # available PP; this can delay the AI from pursuing easy invasion targets.
        # Queue an extra troopship if the following conditions are met:
        #   i) currently dealing with our Capital ResourceGroup
        #   ii) the invasion priority for this group is nonzero and the max priority, and
        #   iii) there are minimal troopships already enqueued
        invasion_priority = local_priorities.get(PriorityType.PRODUCTION_INVASION, 0)
        if (
            capital_id in planet_set
            and invasion_priority
            and invasion_priority == max(local_priorities.values())
            and queued_troop_ships <= 2
        ):  # todo get max from character module or otherwise calculate
            best_design_id, best_design, build_choices = best_ships[PriorityType.PRODUCTION_INVASION]
            loc = random.choice(build_choices)
            retval = fo.issueEnqueueShipProductionOrder(best_design_id, loc)
            if retval != 0:
                per_turn_cost = float(best_design.productionCost(empire.empireID, loc)) / best_design.productionTime(
                    empire.empireID, loc
                )
                avail_pp -= per_turn_cost
                debug(
                    "adding extra trooper at location %s to production queue: %s; per turn production cost %.1f\n",
                    PlanetUtilsAI.planet_string(loc),
                    best_design.name,
                    per_turn_cost,
                )

        debug("")
    update_stockpile_use()
    fo.updateProductionQueue()
    print_production_queue(after_turn=True)


def _get_queued_buildings(pid: PlanetId) -> list[BuildingName]:
    debug("Buildings already in Production Queue:")
    capital_queued_buildings = _get_queued_buildings_for_planet(pid)
    for bldg in capital_queued_buildings:
        debug(f"    {bldg.name} turns: {bldg.turnsLeft} PP: {bldg.allocation}")
    if not capital_queued_buildings:
        debug("No capital queued buildings")
    queued_building_names = [bldg.name for bldg in capital_queued_buildings]
    return queued_building_names


def _is_queued_building_on_planet(e: "fo.productionQueueElement", pid: PlanetId) -> bool:
    return e.buildType == EmpireProductionTypes.BT_BUILDING and e.locationID == pid


def _get_queued_buildings_for_planet(pid: PlanetId) -> Sequence["fo.productionQueueElement"]:
    queue = fo.getEmpire().productionQueue
    return [e for e in queue if _is_queued_building_on_planet(e, pid)]


def update_stockpile_use():
    """Decide which elements in the production_queue will be enabled for drawing from the imperial stockpile.  This
    initial version simply ensures that every resource group with at least one item on the queue has its highest
    priority item be stockpile-enabled.

    :return: None
    """
    # TODO: Do a priority and risk evaluation to decide on enabling stockpile draws
    empire = fo.getEmpire()
    production_queue = empire.productionQueue
    resource_groups = {tuple(el.key()) for el in empire.planetsWithAvailablePP}
    planets_in_stockpile_enabled_group = set()
    for queue_index, element in enumerate(production_queue):
        if element.locationID in planets_in_stockpile_enabled_group:
            # TODO: evaluate possibly disabling stockpile for current element if was previously enabled, perhaps
            # only allowing multiple stockpile enabled items in empire-capital-resource-group, or considering some
            # priority analysis
            continue
        group = next((_g for _g in resource_groups if element.locationID in _g), None)
        if group is None:
            continue  # we don't appear to own the location any more
        if fo.issueAllowStockpileProductionOrder(queue_index, True):
            planets_in_stockpile_enabled_group.update(group)


def empire_has_colony_bld_species(building_name: str) -> bool:
    """
    Checks if this building is a colony building for which this empire has the required source species available.
    """
    if not building_name.startswith("BLD_COL_"):
        return False
    species_name = "SP_" + building_name.split("BLD_COL_")[1]
    return can_build_colony_for_species(species_name)


def already_has_completed_colony_building(planet_id) -> bool:
    """
    Checks if a planet has an already-completed (but not yet 'hatched') colony building.
    """
    universe = fo.getUniverse()
    planet = universe.getPlanet(planet_id)
    return any(universe.getBuilding(bldg).name.startswith("BLD_COL_") for bldg in planet.buildingIDs)


def _build_ship_facilities(building_type: Shipyard, top_pids: set[PlanetId] = frozenset()) -> None:
    # TODO: add total_pp checks below, so don't overload queue
    if not building_type.available():
        return
    universe = fo.getUniverse()
    total_pp = fo.getEmpire().productionPoints
    prerequisite_type = building_type.prerequisite()
    queued_bld_pids = building_type.queued_at()
    if building_type in Shipyard.get_system_ship_facilities():
        current_coverage = building_type.built_or_queued_at_sys()
        open_systems = {
            universe.getPlanet(pid).systemID for pid in get_best_pilot_facilities(Shipyard.BASE.value)
        }.difference(current_coverage)
        try_systems = open_systems & prerequisite_type.built_or_queued_at_sys() if prerequisite_type else open_systems
        try_pids = {pid for sys_id in try_systems for pid in get_owned_planets_in_system(sys_id)}
    else:
        current_pids = get_best_pilot_facilities(building_type.value)
        try_pids = get_best_pilot_facilities(prerequisite_type.value).difference(queued_bld_pids, current_pids)
    debug(
        "Considering constructing a %s, have %d already built and %d queued",
        building_type,
        len(building_type.built_at()),
        len(building_type.queued_at()),
    )
    if not try_pids:
        # Recursion cannot handle the base yard, but currently the AI builds it everywhere anyway.
        # Production of shipyards should be re-written, the AI builds too many of them.
        if prerequisite_type != Shipyard.BASE:
            debug(f"Cannot build {building_type} at top-pilot planets, try building {prerequisite_type} first.")
            _build_ship_facilities(prerequisite_type, top_pids)
        return
    # ship facilities all have location independent costs
    turn_cost = building_type.turn_cost(list(try_pids)[0])
    max_under_construction = max(1, int(total_pp) // (int(5 * turn_cost)))
    max_total = max(1, int(total_pp) // int(2 * turn_cost))
    debug("Allowances: max total: %d, max under construction: %d", max_total, max_under_construction)
    if len(building_type.built_at()) >= max_total:
        return
    try_top_pids = [pid for pid in try_pids & top_pids if building_type.can_be_produced(pid)]
    try_other_pids = [pid for pid in try_pids - top_pids if building_type.can_be_produced(pid)]
    valid_pids = try_top_pids + try_other_pids
    debug("Have %d potential locations: %s", len(valid_pids), [universe.getPlanet(x) for x in valid_pids])
    # TODO: rank by defense ability, etc.
    num_queued = len(queued_bld_pids)
    already_covered = []  # just those covered on this turn
    while valid_pids:
        if num_queued >= max_under_construction:
            break
        pid = valid_pids.pop()
        if pid in already_covered:
            continue
        res = building_type.enqueue(pid)
        debug("Enqueueing %s at planet %s , with result %d", building_type, universe.getPlanet(pid), res)
        if res:
            num_queued += 1
            already_covered.extend(get_owned_planets_in_system(universe.getPlanet(pid).systemID))


def find_automatic_historic_analyzer_candidates() -> list[int]:
    """
    Find possible locations for the BLD_AUTO_HISTORY_ANALYSER building and return a subset of chosen building locations.

    :return: Random possible locations up to max queueable amount. Empty if no location found or can't queue another one
    """
    empire = fo.getEmpire()
    universe = fo.getUniverse()
    total_pp = empire.productionPoints
    history_analyser = "BLD_AUTO_HISTORY_ANALYSER"
    culture_archives = "BLD_CULTURE_ARCHIVES"

    ARB_LARGE_NUMBER = 1e4
    conditions = {
        # aggression: (min_pp, min_turn, min_pp_to_queue_another_one)
        fo.aggression.beginner: (100, 100, ARB_LARGE_NUMBER),
        fo.aggression.turtle: (75, 75, ARB_LARGE_NUMBER),
        fo.aggression.cautious: (40, 40, ARB_LARGE_NUMBER),
        fo.aggression.typical: (20, 20, 50),
        fo.aggression.aggressive: (10, 10, 40),
        fo.aggression.maniacal: (8, 5, 30),
    }

    min_pp, turn_trigger, min_pp_per_additional = conditions.get(
        get_aistate().character.get_trait(Aggression).key, (ARB_LARGE_NUMBER, ARB_LARGE_NUMBER, ARB_LARGE_NUMBER)
    )
    max_enqueued = 1 if total_pp > min_pp or fo.currentTurn() > turn_trigger else 0
    max_enqueued += int(total_pp / min_pp_per_additional)

    if max_enqueued <= 0:
        return []

    # find possible locations
    possible_locations = set()
    for pid in get_all_empire_planets():
        planet = universe.getPlanet(pid)
        if not planet or planet.currentMeterValue(fo.meterType.targetPopulation) < 1:
            continue
        buildings_here = [bld.buildingTypeName for bld in map(universe.getBuilding, planet.buildingIDs)]
        if planet and culture_archives in buildings_here and history_analyser not in buildings_here:
            possible_locations.add(pid)

    # check existing queued buildings and remove from possible locations
    queued_locs = {
        e.locationID
        for e in empire.productionQueue
        if e.buildType == EmpireProductionTypes.BT_BUILDING and e.name == history_analyser
    }

    possible_locations -= queued_locs
    chosen_locations = []
    for i in range(min(max_enqueued, len(possible_locations))):
        chosen_locations.append(possible_locations.pop())
    return chosen_locations


def _location_rating(planet: fo.planet) -> float:
    """Get a rating how good this planet would be for a building"""
    # Simple value so far, should be enhanced, e.g. a scanner should go to the planet with species that
    # has the best basic visions
    return planet.currentMeterValue(fo.meterType.maxTroops)


def _try_enqueue(
    building_type: BuildingTypeBase,
    candidates: Union[PlanetId, Iterable[PlanetId]],
    *,
    at_front: bool = False,
    ignore_dislike: bool = False,
) -> float:
    """
    Enqueue building at one of the planets in candidates.
    If at_front, building is added at the front of the queue.
    Returns PP per turn spent on the new building or 0.0 if nothing was enqueued.
    """
    universe = fo.getUniverse()
    empire = fo.getEmpire()
    opinion = building_type.get_opinions()
    locations = []
    preferred_locations = []
    if isinstance(candidates, int):  # isinstance(candidates, PlanetId) does not work, at least not in python-3.9
        candidates = [candidates]
    for pid in candidates:
        planet = universe.getPlanet(pid)
        if not planet:
            error(f"Got pid {pid} in candidate, which does not seem to be a planetID")
            continue
        if not ignore_dislike and pid in opinion.dislikes:
            continue
        if not building_type.can_be_produced(pid) or not building_type.can_be_enqueued(pid):
            continue
        if pid in opinion.likes:
            preferred_locations.append((_location_rating(planet), pid))
        else:
            locations.append((_location_rating(planet), pid))
    for _, pid in sorted(preferred_locations, reverse=True) + sorted(locations, reverse=True):
        planet = universe.getPlanet(pid)
        res = building_type.enqueue(pid)
        debug("Enqueueing %s at planet %d (%s) , with result %d", building_type, pid, planet.name, res)
        if res:
            if at_front:
                res = fo.issueRequeueProductionOrder(empire.productionQueue.size - 1, 0)  # move to front
                debug("Requeueing %s to front of build queue, with result %d", building_type, res)
            return building_type.turn_cost(pid)
    return 0.0


def _may_enqueue_for_stability(building_type: BuildingTypeBase, new_turn_cost: float) -> float:
    """
    Build building if it seems worth doing so to increase stability.
    Only builds of locations.planets_enqueued is empty and new_turn_cost is 0.0,
    i.e. there are currently no build queue entries for the given building.
    returns new_turn_cost or turn_cost of the building enqueued by this function.
    """
    if building_type.queued_at() or new_turn_cost:
        return new_turn_cost
    # this can be improved a lot, taking into account value of planets, actual stability and
    # what effects the change would have. For the moment, keep it simple.
    # Note that the strongest effect is always on the building's planet itself.
    opinion = building_type.get_opinions()
    universe = fo.getUniverse()
    if len(opinion.likes) >= len(opinion.dislikes) * PlanetUtilsAI.dislike_factor():
        like_candidates = opinion.likes - building_type.built_or_queued_at()
        # plans may change, so consider only actual colonies that like it
        candidates = [pid for pid in like_candidates if universe.getPlanet(pid).speciesName]
        return _try_enqueue(building_type, candidates)
    return 0.0


def _build_scanning_facility() -> float:
    """Consider building Scanning Facilities, return added turn costs."""
    building_type = BuildingType.SCANNING_FACILITY
    empire = fo.getEmpire()
    if not building_type.available():
        return 0.0

    universe = fo.getUniverse()
    turn_cost = 0.0
    opinion = building_type.get_opinions()
    # TBD use actual cost?
    max_scanner_builds = max(1, int(empire.productionPoints / 30)) - len(building_type.queued_at())
    scanner_systems = building_type.built_or_queued_at_sys()
    debug(
        "Considering building %s, found current and queued systems %s, planets that like it %s, #dislikes: %d",
        building_type,
        PlanetUtilsAI.sys_name_ids(scanner_systems),
        PlanetUtilsAI.sys_name_ids(opinion.likes),
        len(opinion.dislikes),
    )
    for sys_id in get_owned_planets():
        if max_scanner_builds <= 0:
            break
        if sys_id in scanner_systems:
            continue
        need_scanner = False
        for neighbor in get_neighbors(sys_id):
            if universe.getVisibility(neighbor, empire.empireID) < fo.visibility.partial:
                need_scanner = True
                break
        if not need_scanner:
            continue
        # TBD: chose based on detection range
        cost = _try_enqueue(building_type, get_owned_planets_in_system(sys_id), at_front=True)
        if cost:
            max_scanner_builds -= 1
        turn_cost += cost
    return _may_enqueue_for_stability(building_type, turn_cost)


def _build_gas_giant_generator() -> float:  # noqa: C901
    """Consider building Gas Giant Generators, return added turn costs."""
    building_type = BuildingType.GAS_GIANT_GEN
    if not building_type.available():
        return 0.0

    ggg_min_stability = get_named_real("BLD_GAS_GIANT_GEN_MIN_STABILITY")
    universe = fo.getUniverse()
    colonized_planets = get_colonized_planets()
    opinion = building_type.get_opinions()
    systems = []
    for sys in colonized_planets.keys():
        planets = [(pid, universe.getPlanet(pid)) for pid in get_owned_planets_in_system(sys)]
        if sys in building_type.built_or_queued_at_sys() or fo.planetSize.gasGiant not in [x[1].size for x in planets]:
            continue
        rating = 0
        gas_giant = None
        best_gg = -2
        debug(f"Gas Giant Generator rating for {universe.getSystem(sys).name} ...")
        for pid, planet in planets:
            likes = opinion.value(pid, 1, 0, -1 * PlanetUtilsAI.dislike_factor())
            debug(f"  {planet.name} likes {likes}")
            # TBD -4 if build here...
            stability = planet.currentMeterValue(fo.meterType.targetHappiness) + likes
            rating += 3 * likes
            debug(f"  rating now {rating} from likes {likes} ")
            if planet.size == fo.planetSize.gasGiant:
                val = likes
                if val > best_gg:
                    best_gg = val
                    gas_giant = pid
            if planet.focus == FocusType.FOCUS_INDUSTRY and stability >= ggg_min_stability:
                rating += 20 + min(5, stability - ggg_min_stability)
                debug(f"  rating now {rating} from industry planet stability {stability} ")
            elif FocusType.FOCUS_INDUSTRY in planet.availableFoci and stability >= ggg_min_stability:
                rating += 5 + min(5, stability - ggg_min_stability)
                debug(f"  rating now {rating} from pot. industry planet stability {stability} ")
        if gas_giant:
            # if the inhabitants do not like it, this will require two other planets that profit from it
            rating += 15 * best_gg
            debug(f"  from best_gg {best_gg}, final rating: {rating}")
            systems.append((rating, gas_giant))
    # sorting so that highest ratings come last, which means they end up at the front of the queue
    systems.sort()
    turn_cost = 0.0
    for rating, gas_giant in systems:
        # 20 = one industry planet with exactly ggg_min_stability
        if rating >= 20:
            turn_cost += _try_enqueue(building_type, gas_giant, at_front=True, ignore_dislike=True)
    return _may_enqueue_for_stability(building_type, turn_cost)


def _build_translator():
    """Consider building Near Universal Translators, return added turn costs."""
    building_type = BuildingType.TRANSLATOR
    if building_type.available() and translators_wanted() and candidate_for_translator:
        # starting one per turn should be enough
        have_one = bool(building_type.built_or_queued_at())
        return _try_enqueue(building_type, candidate_for_translator, at_front=not have_one)
    return 0.0
    # may_enqueue_for_stability? Building is rather expensive...


def _build_regional_administration() -> float:
    """Consider building Imperial Regional Administrations, return added turn costs."""
    building_type = BuildingType.REGIONAL_ADMIN
    current_admin_systems = building_type.built_or_queued_at_sys() | BuildingType.PALACE.built_or_queued_at_sys()
    # No administrations at all means no palace. If we cannot even find a place for the palace,
    # there is no point in building regional administrations.
    if not building_type.available() or not current_admin_systems:
        return 0.0
    universe = fo.getUniverse()
    jumps_to_admin = [
        (min(universe.jumpDistance(sys_id, admin) for admin in current_admin_systems), sys_id)
        for sys_id in get_owned_planets()
    ]
    jumps_to_admin.sort(reverse=True)
    # Currently, 6 is required, this is supposed to change. Note that the game allows to enqueue several ones
    # close to each other, but only one will get finished.
    if jumps_to_admin[0][0] < 6:
        return 0.0
    debug(f"current_admin_systems: {current_admin_systems}, jumps_to_admin = {jumps_to_admin}")
    # with minimum distance 6, systems 3 jumps away from current admins cannot get closer
    systems_that_may_profit = [value for value in jumps_to_admin if value[0] > 3]
    best_sys_id = None
    # without a threshold the AI would build at the first planet 6 steps away from others,
    # although it may not give much and is possible quite exposed.
    best_rating = 10.0
    for distance, candidate in jumps_to_admin:
        if distance < 6:
            break
        rating = _rate_system_for_admin(candidate, systems_that_may_profit)
        if rating > best_rating:
            best_sys_id = candidate
            best_rating = rating
    # To add more than one, we'd have to recalculate everything, but one per turn is good enough.
    # In practice more than one would hardly ever be possible anyway.
    debug(f"best_sys_id={best_sys_id}, best_rating={best_rating}, planets: {get_owned_planets_in_system(best_sys_id)}")
    if not best_sys_id:
        return 0.0
    return _try_enqueue(building_type, get_owned_planets_in_system(best_sys_id))


def _rate_system_for_admin(sys_id: SystemId, systems_that_may_profit: list[tuple[int, SystemId]]) -> float:
    opinion = BuildingType.REGIONAL_ADMIN.get_opinions()
    planets = set(get_owned_planets_in_system(sys_id))
    dislikes = planets & opinion.dislikes
    likes = planets & opinion.likes
    if dislikes == planets:
        return 0.0
    # First like gets a big bonus, but we can build it only on one.
    # Number of planets to prefer better defended systems.
    rating = 1.5 * (len(likes) - len(dislikes) * PlanetUtilsAI.dislike_factor()) + 3 * (likes != set()) + len(planets)

    universe = fo.getUniverse()
    for current_distance, other_sys_id in systems_that_may_profit:
        difference = current_distance - universe.jumpDistance(sys_id, other_sys_id)
        if difference > 0:
            for pid in get_colonized_planets_in_system(other_sys_id):
                planet = universe.getPlanet(pid)
                if Tags.INDEPENDENT not in fo.getSpecies(planet.speciesName).tags:
                    stability = planet.currentMeterValue(fo.meterType.targetHappiness)
                    rating += difference
                    # reaching 10 gives a lot of bonuses
                    if stability < 0 or stability < 10 <= stability + difference:
                        rating += 2
    debug(f"admin rating {universe.getSystem(sys_id)}={rating}")
    return rating


def _build_military_command() -> float:
    """
    Consider building a Military Command, return added turn costs.
    Since its major purpose is to provide policy slots, and we may need the production for more important
    things, do not build it too early. Won't build it at all, if all our planets dislike it.
    """
    building_type = BuildingType.MILITARY_COMMAND
    palace_planet = BuildingType.PALACE.built_at()
    # an empire can only build one, and if we do not have a palace, this is definitely more important
    if building_type.built_or_queued_at() or not palace_planet:
        return 0.0
    # cost is independent of the location, but we need a valid location
    if fo.getEmpire().productionPoints > building_type.turn_cost(list(palace_planet)[0]) * 1.5:
        # default selection should prefer the capital, unless its species dislikes it.
        return _try_enqueue(building_type, get_inhabited_planets())
    return 0.0


TopPilotSystems = NewType("TopPilotSystems", dict[SystemId, list[tuple[PlanetId, float]]])


class ShipYardInfo(NamedTuple):
    queued_shipyard_pids: list[PlanetId]
    colony_systems: dict[PlanetId, SystemId]
    top_pilot_systems: TopPilotSystems


def _build_basic_shipyards() -> ShipYardInfo:  # noqa: C901
    """
    Consider building basic ship yards and also determine some value needed for other shipyard buildings.
    """
    building_type = Shipyard.BASE
    universe = fo.getUniverse()
    queued_shipyard_pids = building_type.queued_at()
    system_colonies = {}
    colony_systems = {}
    empire_species = get_empire_planets_by_species()
    for spec_name in get_colony_builders():
        if not get_colony_builder_locations(spec_name) and (
            spec_name in empire_species
        ):  # not enough current shipyards for this species #TODO: also allow orbital incubators and/or asteroid ships
            for pid in get_empire_planets_with_species(
                spec_name
            ):  # SP_EXOBOT may not actually have a colony yet but be in empireColonizers
                if pid in queued_shipyard_pids:
                    break  # won't try building more than one shipyard at once, per colonizer
            else:
                # no queued shipyards: get planets with target pop >=3 and
                # queue a shipyard on the one with the biggest current population
                planets = (universe.getPlanet(x) for x in get_empire_planets_with_species(spec_name))
                pops = sorted(
                    (planet_.initialMeterValue(fo.meterType.population), planet_.id)
                    for planet_ in planets
                    if (planet_ and planet_.initialMeterValue(fo.meterType.targetPopulation) >= 3.0)
                )
                pids = [pid for pop, pid in pops if building_type.can_be_produced(pid)]
                if pids:
                    build_loc = pids[-1]
                    res = _try_enqueue(building_type, build_loc)  # do not ignore dislikes here
                    if res > 0:
                        queued_shipyard_pids.append(build_loc)
                        break  # only start at most one new shipyard per species per turn
        for pid in get_empire_planets_with_species(spec_name):
            planet = universe.getPlanet(pid)
            if planet:
                system_colonies.setdefault(planet.systemID, {}).setdefault("pids", []).append(pid)
                colony_systems[pid] = planet.systemID

    for pid in get_empire_planets_with_species("SP_ACIREMA"):
        if (pid in queued_shipyard_pids) or not building_type.can_be_produced(pid):
            continue  # but not 'break' because we want to build shipyards at *every* Acirema planet
        # currently Acirema do not dislike ship yards, but if that changes, do not build shipyards anymore
        res = _try_enqueue(building_type, pid, at_front=True)
        if res > 0:
            queued_shipyard_pids.append(pid)

    top_pilot_systems = TopPilotSystems({})
    for pid, rating in get_pilot_ratings().items():
        if (rating <= medium_pilot_rating()) and (rating < GREAT_PILOT_RATING):
            continue
        top_pilot_systems.setdefault(universe.getPlanet(pid).systemID, []).append((pid, rating))
        if (pid in queued_shipyard_pids) or not building_type.can_be_produced(pid):
            continue  # but not 'break' because we want to build shipyards all top pilot planets
        # so far we ignore dislikes here, but this may have to change for Mu Ursh
        res = _try_enqueue(building_type, pid, at_front=True, ignore_dislike=True)
        if res:
            queued_shipyard_pids.append(pid)
    return ShipYardInfo(queued_shipyard_pids, colony_systems, top_pilot_systems)  # TBD return added costs?


def _build_energy_shipyards(  # noqa: C901
    queued_shipyard_pids: list[PlanetId],
    colony_systems: dict[PlanetId, SystemId],
    building_ratio: float,
    building_expense: float,
) -> tuple[list[tuple[float, PlanetId]], list[tuple[float, PlanetId]], float]:
    """
    Consider building Energy Compressor and Solar Containment Unit.
    Also determines pilot rating for planets in system with red stars and black holes.
    Returns blackhole_pilots, red_pilots and new value of building_expense.
    """
    universe = fo.getUniverse()
    empire = fo.getEmpire()
    pop_ctrs = list(get_inhabited_planets())
    red_population_centres = sorted(
        [
            (get_rating_for_planet(pid), pid)
            for pid in pop_ctrs
            if colony_systems.get(pid, INVALID_ID) in AIstate.empireStars.get(fo.starType.red, [])
        ],
        reverse=True,
    )
    red_pilots = [pid for _, pid in red_population_centres if _ == best_pilot_rating()]
    blue_population_centres = sorted(
        [
            (get_rating_for_planet(pid), pid)
            for pid in pop_ctrs
            if colony_systems.get(pid, INVALID_ID) in AIstate.empireStars.get(fo.starType.blue, [])
        ],
        reverse=True,
    )
    blue_pilots = [pid for _, pid in blue_population_centres if _ == best_pilot_rating()]
    blackhole_pilots = sorted(
        [
            (get_rating_for_planet(pid), pid)
            for pid in pop_ctrs
            if colony_systems.get(pid, INVALID_ID) in AIstate.empireStars.get(fo.starType.blackHole, [])
        ],
        reverse=True,
    )
    blackhole_pilots = [pid for _, pid in blackhole_pilots if _ == best_pilot_rating()]
    energy_shipyard_pids = {}
    building_type = Shipyard.ENRG_COMP
    if building_type.available():
        queued_building_pids = building_type.queued_at()
        for pid in blackhole_pilots + blue_pilots:
            if len(queued_building_pids) > 1:  # build a max of 2 at once
                break
            this_planet = universe.getPlanet(pid)
            if not (
                this_planet and can_build_ship_for_species(this_planet.speciesName)
            ):  # TODO: also check that not already one for this spec in this sys
                continue
            energy_shipyard_pids.setdefault(this_planet.systemID, []).append(pid)
            if pid not in queued_building_pids and building_type.can_be_produced(pid):
                building_expense += _try_enqueue(building_type, pid, at_front=True)

    building_type = Shipyard.ENRG_SOLAR
    if building_type.available() and not building_type.queued_at():
        # TODO: check that production is not frozen at a queued location
        for pid in blackhole_pilots:
            this_planet = universe.getPlanet(pid)
            if not (
                this_planet and can_build_ship_for_species(this_planet.speciesName)
            ):  # TODO: also check that not already one for this spec in this sys
                continue
            if building_type.can_be_produced(pid):
                building_expense += _try_enqueue(building_type, pid, at_front=True)

    total_pp = empire.productionPoints
    building_type = Shipyard.BASE
    if building_type.available() and (building_expense < building_ratio * total_pp) and (total_pp > 50):
        for sys_id in energy_shipyard_pids:  # Todo ensure only one or 2 per sys
            # only start one per turn (TBD why [:2]?)
            for pid in energy_shipyard_pids[sys_id][:2]:
                res = _try_enqueue(building_type, pid, at_front=True)
                if res > 0:
                    queued_shipyard_pids.append(pid)
                    break  # only start one per turn
    return blackhole_pilots, red_pilots, building_expense


def _build_asteroid_processor(  # noqa: C901
    top_pilot_systems: TopPilotSystems, queued_shipyard_pids: list[PlanetId]
) -> float:
    """Consider building asteroid processor, return added turn costs."""
    building_type = Shipyard.ASTEROID
    building_expense = 0.0
    if building_type.available():
        universe = fo.getUniverse()
        queued_building_pids = building_type.queued_at()
        if not queued_building_pids:
            asteroid_systems = {}
            asteroid_yards = {}
            builder_systems = {}
            for pid in get_all_empire_planets():
                planet = universe.getPlanet(pid)
                this_spec = planet.speciesName
                sys_id = planet.systemID
                if planet.size == fo.planetSize.asteroids and sys_id in get_colonized_planets():
                    asteroid_systems.setdefault(sys_id, []).append(pid)
                    if pid in building_type.built_or_queued_at():
                        asteroid_yards[sys_id] = pid  # shouldn't ever overwrite another, but ok if it did
                if can_build_ship_for_species(this_spec):
                    if pid not in get_ship_builder_locations(this_spec):
                        builder_systems.setdefault(sys_id, []).append((planet.speciesName, pid))
            # check if we need to build another asteroid processor:
            # check if local shipyard to go with the asteroid processor
            yard_systems = []
            need_yard = {}
            top_pilot_locations = []
            for sys_id in set(asteroid_systems.keys()).difference(asteroid_yards.keys()):
                if sys_id in top_pilot_systems:
                    for pid, rating in top_pilot_systems[sys_id]:
                        if pid not in queued_shipyard_pids:  # will catch it later if shipyard already present
                            top_pilot_locations.append((rating, pid, sys_id))
            top_pilot_locations.sort(reverse=True)
            for _, _, sys_id in top_pilot_locations:
                if sys_id not in yard_systems:
                    yard_systems.append(sys_id)  # prioritize asteroid yards for acirema and/or other top pilots
                    for pid, _ in top_pilot_systems[sys_id]:
                        if pid not in queued_shipyard_pids:  # will catch it later if shipyard already present
                            need_yard[sys_id] = pid
            if (not yard_systems) and len(asteroid_yards.values()) <= int(
                fo.currentTurn() // 50
            ):  # not yet building & not enough current locs, find a location to build one
                colonizer_loc_choices = []
                builder_loc_choices = []
                bld_systems = set(asteroid_systems.keys()).difference(asteroid_yards.keys())
                for sys_id in bld_systems.intersection(builder_systems.keys()):
                    for this_spec, pid in builder_systems[sys_id]:
                        if can_build_colony_for_species(this_spec):
                            if pid in (get_colony_builder_locations(this_spec) + queued_shipyard_pids):
                                colonizer_loc_choices.insert(0, sys_id)
                            else:
                                colonizer_loc_choices.append(sys_id)
                                need_yard[sys_id] = pid
                        else:
                            if pid in (get_ship_builder_locations(this_spec) + queued_shipyard_pids):
                                builder_loc_choices.insert(0, sys_id)
                            else:
                                builder_loc_choices.append(sys_id)
                                need_yard[sys_id] = pid
                yard_systems.extend(
                    (colonizer_loc_choices + builder_loc_choices)[:1]
                )  # add at most one of these non top pilot locs
            new_yard_count = len(queued_building_pids)
            for sys_id in yard_systems:  # build at most 2 new asteroid yards at a time
                if new_yard_count >= 2:
                    break
                pid = asteroid_systems[sys_id][0]
                if sys_id in need_yard:
                    pid2 = need_yard[sys_id]
                    res = _try_enqueue(Shipyard.BASE, pid2, at_front=True)
                    if res > 0:
                        queued_shipyard_pids.append(pid2)
                        building_expense += res
                if pid not in queued_building_pids and building_type.can_be_produced(pid):
                    res = _try_enqueue(building_type, pid, at_front=True)
                    if res > 0:
                        new_yard_count += 1
                        queued_building_pids.append(pid)
                        building_expense += res
    return building_expense


def _build_orbital_drydock(top_pilot_systems: TopPilotSystems) -> None:  # noqa: C901
    """Consider building orbital drydocks."""
    building_type = Shipyard.ORBITAL_DRYDOCK
    if building_type.available():
        empire = fo.getEmpire()
        universe = fo.getUniverse()
        queued_pids = building_type.queued_at()
        current_drydock_sys = building_type.built_or_queued_at_sys()
        covered_drydock_systems = set()
        for start_set, dest_set in [
            (current_drydock_sys, covered_drydock_systems),
            (covered_drydock_systems, covered_drydock_systems),
        ]:  # coverage of neighbors up to 2 jumps away from a drydock
            for dd_sys_id in start_set.copy():
                dest_set.add(dd_sys_id)
                dest_set.update(get_neighbors(dd_sys_id))

        max_dock_builds = int(0.8 + empire.productionPoints / 120.0)
        debug(
            "Considering building %s, found current and queued systems %s",
            building_type,
            PlanetUtilsAI.sys_name_ids(current_drydock_sys),
        )
        for sys_id, pids in get_colonized_planets().items():  # TODO: sort/prioritize in some fashion
            local_top_pilots = dict(top_pilot_systems.get(sys_id, []))
            local_drydocks = get_empire_drydocks().get(sys_id, [])
            if len(queued_pids) >= max_dock_builds:
                debug("Drydock enqueing halted with %d of max %d", len(queued_pids), max_dock_builds)
                break
            if (sys_id in covered_drydock_systems) and not local_top_pilots:
                continue
            else:
                pass
            for _, pid in sorted([(local_top_pilots.get(pid, 0), pid) for pid in pids], reverse=True):
                if has_shipyard(pid):
                    continue
                if pid in local_drydocks or pid in queued_pids:
                    break
                if not building_type.can_be_enqueued(pid):
                    continue
                res = _try_enqueue(building_type, pid, at_front=(max_dock_builds >= 2))
                if res > 0:
                    queued_pids.append(pid)
                    system_id = universe.getPlanet(pid).systemID
                    covered_drydock_systems.add(system_id)
                    covered_drydock_systems.update(get_neighbors(system_id))


def _remove_other_colonies(pid: PlanetId, building_name: str) -> None:
    """
    Removes enqueued colony buildings at the given planet.
    Since colonies cannot be queued in parallel, to allow enqueuing building_name, all others must be removed.
    If building_name is already enqueued, it's fine of course.
    """
    numbered_queue = list(enumerate(fo.getEmpire().productionQueue))
    # It should not be more than one, except possibly when loading an old safe file, just to be sure, remove all.
    # Start at the end to avoid changing the numbers of further elements when removing one.
    for num, entry in reversed(numbered_queue):
        if entry.locationID == pid and entry.name.startswith("BLD_COL_") and entry.name != building_name:
            fo.issueDequeueProductionOrder(num)