From f5882ccea570e670e6bd2d9016b24faced88663b Mon Sep 17 00:00:00 2001 From: Wrycu Date: Fri, 27 Mar 2020 22:19:37 -0700 Subject: [PATCH] feat(game): improve air defenses * add multi-unit SAM sites * always spawn full air defenses for bases * increase max distance for spawning air defenses (since we have higher-threat units now * add chance of air defense to spawn at FARPs * enemy takes their turn even if you pass yours known bugs: * destroying the radar and all launchers of a single site is tracked as two SAM site kills --- game/db.py | 60 ++++++-- game/operation/operation.py | 1 + gen/aaa.py | 291 +++++++++++++++++++++++++++++++++++- gen/groundobjectsgen.py | 46 +++++- theater/base.py | 45 +++++- theater/caucasus.py | 4 +- userdata/debriefing.py | 2 +- 7 files changed, 427 insertions(+), 22 deletions(-) diff --git a/game/db.py b/game/db.py index 7304f70..925688c 100644 --- a/game/db.py +++ b/game/db.py @@ -119,9 +119,14 @@ class Default(Enum): AirDefence.AAA_Vulcan_M163: 5, AirDefence.SAM_Linebacker_M6: 10, - AirDefence.SPAAA_ZSU_23_4_Shilka: 8, - AirDefence.SAM_SA_9_Strela_1_9P31: 13, - AirDefence.SAM_SA_8_Osa_9A33: 18, + AirDefence.AAA_ZU_23_Closed: 2, + AirDefence.SPAAA_ZSU_23_4_Shilka: 4, + AirDefence.SAM_SA_9_Strela_1_9P31: 8, + AirDefence.SAM_SA_19_Tunguska_2S6: 15, + AirDefence.SAM_SA_6_Kub_LN_2P25: 22, + AirDefence.SAM_SA_8_Osa_9A33: 12, + AirDefence.SAM_SA_3_S_125_LN_5P73: 35, + AirDefence.SAM_SA_11_Buk_LN_9A310M1: 25, # ship CV_1143_5_Admiral_Kuznetsov: 100, @@ -179,7 +184,6 @@ class Default(Enum): Ka_50, SA342M, ], - Transport: [ IL_76MD, An_26B, @@ -188,15 +192,12 @@ class Default(Enum): C_130, ], - Refueling: [ IL_78M, KC_135, S_3B_Tanker, ], - AWACS: [E_3A, A_50, ], - PinpointStrike: [ Armor.APC_BTR_80, Armor.APC_BTR_80, @@ -223,17 +224,17 @@ class Default(Enum): AirDefence.SAM_Linebacker_M6, AirDefence.SPAAA_ZSU_23_4_Shilka, - AirDefence.SPAAA_ZSU_23_4_Shilka, - AirDefence.SPAAA_ZSU_23_4_Shilka, - AirDefence.SAM_SA_9_Strela_1_9P31, + AirDefence.AAA_ZU_23_Closed, AirDefence.SAM_SA_9_Strela_1_9P31, AirDefence.SAM_SA_8_Osa_9A33, + AirDefence.SAM_SA_19_Tunguska_2S6, + AirDefence.SAM_SA_6_Kub_LN_2P25, + AirDefence.SAM_SA_3_S_125_LN_5P73, + AirDefence.SAM_SA_11_Buk_LN_9A310M1, ], - Reconnaissance: [Unarmed.Transport_M818, Unarmed.Transport_Ural_375, Unarmed.Transport_UAZ_469], Nothing: [Infantry.Infantry_M4, Infantry.Soldier_AK, ], Embarking: [UH_1H, Mi_8MT, ], - Carriage: [CVN_74_John_C__Stennis, LHA_1_Tarawa, CV_1143_5_Admiral_Kuznetsov, ], CargoTransportation: [Dry_cargo_ship_Ivanov, Bulk_cargo_ship_Yakushev, Tanker_Elnya_160, Armed_speedboat, ], } @@ -246,8 +247,35 @@ class Default(Enum): AirDefence.SAM_SA_9_Strela_1_9P31, AirDefence.SAM_SA_8_Osa_9A33, + AirDefence.SAM_SA_19_Tunguska_2S6, + AirDefence.SAM_SA_6_Kub_LN_2P25, + AirDefence.SAM_SA_8_Osa_9A33, + AirDefence.SAM_SA_3_S_125_LN_5P73, + AirDefence.SAM_SA_11_Buk_LN_9A310M1, ] +""" +Used to convert SAM site parts to the corresponding site +""" +SAM_CONVERT = { + AirDefence.SAM_SR_P_19: AirDefence.SAM_SA_3_S_125_LN_5P73, + AirDefence.SAM_SA_3_S_125_TR_SNR: AirDefence.SAM_SA_3_S_125_LN_5P73, + AirDefence.SAM_SA_3_S_125_LN_5P73: AirDefence.SAM_SA_3_S_125_LN_5P73, + AirDefence.SAM_SA_6_Kub_LN_2P25: AirDefence.SAM_SA_6_Kub_LN_2P25, + AirDefence.SAM_SA_6_Kub_STR_9S91: AirDefence.SAM_SA_6_Kub_LN_2P25, + AirDefence.SAM_SA_10_S_300PS_LN_5P85C: AirDefence.SAM_SA_10_S_300PS_LN_5P85C, + AirDefence.SAM_SA_10_S_300PS_SR_5N66M: AirDefence.SAM_SA_10_S_300PS_LN_5P85C, + AirDefence.SAM_SA_10_S_300PS_TR_30N6: AirDefence.SAM_SA_10_S_300PS_LN_5P85C, + AirDefence.SAM_SA_10_S_300PS_CP_54K6: AirDefence.SAM_SA_10_S_300PS_LN_5P85C, + AirDefence.SAM_SA_10_S_300PS_SR_64H6E: AirDefence.SAM_SA_10_S_300PS_CP_54K6, + 'except': { + # this radar is shared between the two S300's. if we attempt to find a SAM site at a base and can't find one + # model, we can safely assume the other was deployed + # well, perhaps not safely, but we'll make the assumption anyway :p + AirDefence.SAM_SA_10_S_300PS_TR_30N6: AirDefence.SAM_SA_10_S_300PS_CP_54K6, + } +} + """ Units that will always be spawned in the air """ @@ -306,6 +334,11 @@ class Default(Enum): AirDefence.SPAAA_ZSU_23_4_Shilka, AirDefence.SAM_SA_9_Strela_1_9P31, AirDefence.SAM_SA_8_Osa_9A33, + AirDefence.AAA_ZU_23_Closed, + AirDefence.SAM_SA_19_Tunguska_2S6, + AirDefence.SAM_SA_6_Kub_LN_2P25, + AirDefence.SAM_SA_3_S_125_LN_5P73, + AirDefence.SAM_SA_11_Buk_LN_9A310M1, Armor.APC_BTR_80, Armor.MBT_T_90, @@ -473,7 +506,8 @@ def unit_task(unit: UnitType) -> Task: for task, units in UNIT_BY_TASK.items(): if unit in units: return task - + elif unit in SAM_CONVERT: + return AirDefence assert False diff --git a/game/operation/operation.py b/game/operation/operation.py index 69a2cfb..a430ea0 100644 --- a/game/operation/operation.py +++ b/game/operation/operation.py @@ -83,6 +83,7 @@ def prepare(self, terrain: Terrain, is_quick: bool): with open("resources/default_options.lua", "r") as f: options_dict = loads(f.read())["options"] + dcs.Mission.aaa_vehicle_group = aaa.aaa_vehicle_group self.current_mission = dcs.Mission(terrain) if is_quick: self.quick_mission = self.current_mission diff --git a/gen/aaa.py b/gen/aaa.py index e0568e0..73d5c08 100644 --- a/gen/aaa.py +++ b/gen/aaa.py @@ -1,3 +1,5 @@ +import random +import math from .conflictgen import * from .naming import * @@ -9,6 +11,293 @@ EXTRA_AA_POSITION_FROM_CP = 550 +def num_sam_dead(sam_type, destroyed_count): + """ + Given a type and count of SAM units, determine if enough units were destroyed to warrant the + loss of a site + :param sam_type: + individual unit name in SAM site which was destroyed + :param destroyed_count: + count of that unit type which was destroyed *in the sortie* + :return: + INT: number of sites lost + """ + sam_threshold = { + AirDefence.SAM_SR_P_19: 1, + AirDefence.SAM_SA_3_S_125_TR_SNR: 1, + AirDefence.SAM_SA_6_Kub_STR_9S91: 1, + AirDefence.SAM_SA_10_S_300PS_SR_5N66M: 1, + AirDefence.SAM_SA_10_S_300PS_TR_30N6: 1, + AirDefence.SAM_SA_10_S_300PS_CP_54K6: 1, + AirDefence.SAM_SA_10_S_300PS_SR_64H6E: 1, + AirDefence.SAM_SA_3_S_125_LN_5P73: 4, + AirDefence.SAM_SA_6_Kub_LN_2P25: 6, + AirDefence.SAM_SA_10_S_300PS_LN_5P85C: 8, + } + + return int(destroyed_count / sam_threshold[sam_type]) + + +def determine_positions(position, heading, num_units, launcher_distance, coverage=90): + """ + Given a position on the map, array a group of units in a circle a uniform distance from the unit + :param position: + position of the center unit + :param heading: + the direction the units should be arranged toward if coverage is not 360 + :param num_units: + number of units to play on the circle + :param launcher_distance: + distance the units should be from the center unit + :param coverage: + 0-360 + :return: + list of tuples representing each unit location + [(pos_x, pos_y, heading), ...] + """ + if coverage == 360: + # one of the positions is shared :'( + outer_offset = coverage / num_units + else: + outer_offset = coverage / (num_units - 1) + + positions = [] + + if num_units % 2 == 0: + current_offset = heading - ((coverage / (num_units - 1)) / 2) + else: + current_offset = heading + current_offset -= outer_offset * (math.ceil(num_units / 2) - 1) + for x in range(1, num_units + 1): + positions.append(( + position.x + launcher_distance * math.cos(math.radians(current_offset)), + position.y + launcher_distance * math.sin(math.radians(current_offset)), + current_offset, + )) + current_offset += outer_offset + return positions + + +def aaa_vehicle_group(self, country, name, _type: unittype.VehicleType, position: mapping.Point, + heading=0, group_size=1, + formation=unitgroup.VehicleGroup.Formation.Line, + move_formation: PointAction=PointAction.OffRoad): + """ + Override the default vehicle group so that our group can contain a mix of units (which is required for advanced + SAM sites) + For further docstrings, see the built-in function + """ + vg = unitgroup.VehicleGroup(self.next_group_id(), self.string(name)) + + for i in range(1, group_size + 1): + heading = randint(0, 359) + if _type == AirDefence.SAM_SA_3_S_125_LN_5P73: + # 4 launchers (180 degrees all facing the same direction), 1 SR, 1 TR + num_launchers = 4 + # search radar + v = self.vehicle( + name + " Unit #{nr}-sr".format(nr=i), + AirDefence.SAM_SR_P_19, + ) + v.position.x = position.x + v.position.y = position.y + (i - 1) * 20 + v.heading = heading + vg.add_unit(v) + # track radar + v = self.vehicle( + name + " Unit #{nr}-tr".format(nr=i), + AirDefence.SAM_SA_3_S_125_TR_SNR, + ) + + center_x = position.x + randint(20, 40) + center_y = position.y + (i - 1) * 20 + + v.position.x = center_x + v.position.y = center_y + v.heading = heading + vg.add_unit(v) + plop_positions = determine_positions( + position, + heading, + num_launchers, + launcher_distance=100, + coverage=180, + ) + for x in range(0, num_launchers): + v = self.vehicle( + name + " Unit #{nr}-{x}".format(nr=i, x=x), + AirDefence.SAM_SA_3_S_125_LN_5P73, + ) + + v.position.x = plop_positions[x][0] + v.position.y = plop_positions[x][1] + v.heading = plop_positions[x][2] + vg.add_unit(v) + + elif _type == AirDefence.SAM_SA_6_Kub_LN_2P25: + # 6 launchers (360 degree coverage) + # 1 S/TR + # search/track radar + num_launchers = 6 + v = self.vehicle( + name + " Unit #{nr}-str".format(nr=i), + AirDefence.SAM_SA_6_Kub_STR_9S91, + ) + v.position.x = position.x + v.position.y = position.y + (i - 1) * 20 + v.heading = heading + vg.add_unit(v) + + plop_positions = determine_positions( + position, + heading, + num_launchers, + launcher_distance=100, + coverage=360, + ) + for x in range(0, num_launchers): + v = self.vehicle( + name + " Unit #{nr}-{x}".format(nr=i, x=x), + AirDefence.SAM_SA_6_Kub_LN_2P25, + ) + + v.position.x = plop_positions[x][0] + v.position.y = plop_positions[x][1] + v.heading = plop_positions[x][2] + vg.add_unit(v) + elif _type == AirDefence.SAM_SA_10_S_300PS_LN_5P85C: + # 8 launchers - 4 directions, two in each direction + # 1 SR (offset) + # 1 TR (center) + # search radar + num_launchers = 8 + v = self.vehicle( + name + " Unit #{nr}-sr".format(nr=i), + AirDefence.SAM_SA_10_S_300PS_SR_5N66M, + ) + v.position.x = position.x + v.position.y = position.y + (i - 1) * 20 + v.heading = heading + vg.add_unit(v) + # track radar + v = self.vehicle( + name + " Unit #{nr}-tr".format(nr=i), + AirDefence.SAM_SA_10_S_300PS_TR_30N6, + ) + + center_x = position.x + randint(20, 40) + center_y = position.y + (i - 1) * 20 + + v.position.x = center_x + v.position.y = center_y + v.heading = heading + vg.add_unit(v) + # command center + v = self.vehicle( + name + " Unit #{nr}-c".format(nr=i), + AirDefence.SAM_SA_10_S_300PS_CP_54K6, + ) + + center_x = position.x + randint(40, 60) + center_y = position.y + (i - 1) * 20 + + v.position.x = center_x + v.position.y = center_y + v.heading = heading + vg.add_unit(v) + + plop_positions = determine_positions( + position, + heading, + num_launchers, + launcher_distance=150, + coverage=360, + ) + for x in range(0, num_launchers): + v = self.vehicle( + name + " Unit #{nr}-{x}".format(nr=i, x=x), + AirDefence.SAM_SA_10_S_300PS_LN_5P85C, + ) + + v.position.x = plop_positions[x][0] + v.position.y = plop_positions[x][1] + v.heading = plop_positions[x][2] + vg.add_unit(v) + + elif _type == AirDefence.SAM_SA_10_S_300PS_CP_54K6: + # 8 launchers - 4 directions, two in each direction + # 1 SR (offset) + # 1 TR (center) + # search radar + num_launchers = 8 + v = self.vehicle( + name + " Unit #{nr}-sr".format(nr=i), + AirDefence.SAM_SA_10_S_300PS_SR_64H6E, + ) + v.position.x = position.x + v.position.y = position.y + (i - 1) * 20 + v.heading = heading + vg.add_unit(v) + # track radar + v = self.vehicle( + name + " Unit #{nr}-tr".format(nr=i), + AirDefence.SAM_SA_10_S_300PS_TR_30N6, + ) + + center_x = position.x + randint(20, 40) + center_y = position.y + (i - 1) * 20 + + v.position.x = center_x + v.position.y = center_y + v.heading = heading + vg.add_unit(v) + # command center + v = self.vehicle( + name + " Unit #{nr}-c".format(nr=i), + AirDefence.SAM_SA_10_S_300PS_CP_54K6, + ) + + center_x = position.x + randint(40, 60) + center_y = position.y + (i - 1) * 20 + + v.position.x = center_x + v.position.y = center_y + v.heading = heading + vg.add_unit(v) + + plop_positions = determine_positions( + position, + heading, + num_units=num_launchers, + launcher_distance=150, + coverage=360, + ) + for x in range(0, num_launchers): + v = self.vehicle( + name + " Unit #{nr}-{x}".format(nr=i, x=x), + AirDefence.SAM_SA_10_S_300PS_LN_5P85D, + ) + + v.position.x = plop_positions[x][0] + v.position.y = plop_positions[x][1] + v.heading = plop_positions[x][2] + vg.add_unit(v) + else: + v = self.vehicle(name + " Unit #{nr}-sam".format(nr=i), _type) + v.position.x = position.x + v.position.y = position.y + (i - 1) * 20 + v.heading = heading + vg.add_unit(v) + + wp = vg.add_waypoint(vg.units[0].position, move_formation, 0) + wp.ETA_locked = True + if _type.eplrs: + wp.tasks.append(task.EPLRS(self.next_eplrs("vehicle"))) + + country.add_vehicle_group(vg) + return vg + + class AAConflictGenerator: def __init__(self, mission: Mission, conflict: Conflict): self.m = mission @@ -33,7 +322,7 @@ def generate(self, units: db.AirDefenseDict): ) p = self.conflict.position.point_from_heading(random.choice(self.conflict.radials), distance) - self.m.vehicle_group( + self.m.aaa_vehicle_group( country=self.conflict.defenders_side, name=namegen.next_unit_name(self.conflict.defenders_side, type), _type=type, diff --git a/gen/groundobjectsgen.py b/gen/groundobjectsgen.py index 9ec1cb3..e0b4b91 100644 --- a/gen/groundobjectsgen.py +++ b/gen/groundobjectsgen.py @@ -50,10 +50,10 @@ def generate(self): else: cp = self.conflict.from_cp + consumed_farps = set() + for ground_object in cp.ground_objects: if ground_object.dcs_identifier == "AA": - if ground_object.position.distance_to_point(self.conflict.from_cp.position) < AA_CP_MIN_DISTANCE: - continue if ground_object.is_dead: continue @@ -61,7 +61,7 @@ def generate(self): unit_type = random.choice(self.game.commision_unit_types(cp, AirDefence)) assert unit_type is not None, "Cannot find unit type for GroundObject defense ({})!".format(cp) - group = self.m.vehicle_group( + group = self.m.aaa_vehicle_group( country=side, name=ground_object.string_identifier, _type=unit_type, @@ -80,6 +80,16 @@ def generate(self): print("Didn't find {} in static _map(s)!".format(ground_object.dcs_identifier)) continue + if ground_object.group_id not in consumed_farps: + consumed_farps.add(ground_object.group_id) + if random.randint(0, 100) > 50: + farp_aa( + self.m, + side, + ground_object.string_identifier, + ground_object.position, + ) + group = self.m.static_group( country=side, name=ground_object.string_identifier, @@ -90,3 +100,33 @@ def generate(self): ) logging.info("generated {}object identifier {} with mission id {}".format("dead " if ground_object.is_dead else "", group.name, group.id)) + + +def farp_aa(mission_obj, country, name, position: mapping.Point): + """ + Add AAA to a FARP :) + :param mission_obj: + :param country: + :param name: + :param position: + :return: + """ + vg = unitgroup.VehicleGroup(mission_obj.next_group_id(), mission_obj.string(name)) + + units = [ + AirDefence.SPAAA_ZSU_23_4_Shilka, + AirDefence.AAA_ZU_23_Closed, + Armor.MBT_T_55, + ] + + v = mission_obj.vehicle(name + "_AAA", random.choice(units)) + v.position.x = position.x - random.randint(5, 30) + v.position.y = position.y - random.randint(5, 30) + v.heading = random.randint(0, 359) + vg.add_unit(v) + + wp = vg.add_waypoint(vg.units[0].position, PointAction.OffRoad, 0) + wp.ETA_locked = True + + country.add_vehicle_group(vg) + return vg diff --git a/theater/base.py b/theater/base.py index 3a7aa7c..76afab3 100644 --- a/theater/base.py +++ b/theater/base.py @@ -8,6 +8,7 @@ from dcs.task import * from game import db +from gen import aaa STRENGTH_AA_ASSEMBLE_MIN = 0.2 PLANES_SCRAMBLE_MIN_BASE = 2 @@ -119,12 +120,33 @@ def commision_units(self, units: typing.Dict[typing.Any, int]): target_dict[unit_type] = target_dict.get(unit_type, 0) + unit_count def commit_losses(self, units_lost: typing.Dict[typing.Any, int]): + # advanced SAM sites have multiple units - this code was not at all set up to handle that + # to avoid having to restructure a bunch of upstream code, we track total destroyed units and + # use that to determine if a site was destroyed + # this can be thought of as the enemy re-distributing parts of SAM sites to keep as many + # operational as possible (pulling specific units from ...storage... to bring them back online + # if non-letal damage was done) + # in the future, I may add more depth to this (e.g. a base having a certain number of spares and tracking + # the number of pieces of each site), but for now this is what we get + sams_destroyed = {} + # we count complex SAM sites at the end - don't double count + aa_skip = [ + AirDefence.SAM_SA_6_Kub_LN_2P25, + AirDefence.SAM_SA_3_S_125_LN_5P73, + AirDefence.SAM_SA_11_Buk_LN_9A310M1 + ] for unit_type, count in units_lost.items(): + if unit_type in db.SAM_CONVERT or unit_type in db.SAM_CONVERT['except']: + # unit is part of an advanced SAM site, which means it will fail the below check + try: + sams_destroyed[unit_type] += 1 + except KeyError: + sams_destroyed[unit_type] = 1 if unit_type in self.aircraft: target_array = self.aircraft elif unit_type in self.armor: target_array = self.armor - elif unit_type in self.aa: + elif unit_type in self.aa and unit_type not in aa_skip: target_array = self.aa else: print("Base didn't find event type {}".format(unit_type)) @@ -135,9 +157,28 @@ def commit_losses(self, units_lost: typing.Dict[typing.Any, int]): continue target_array[unit_type] = max(target_array[unit_type] - count, 0) - if target_array[unit_type] == 0: + if target_array[unit_type] <= 0: del target_array[unit_type] + # now that we have a complete picture of the SAM sites destroyed, determine if any were destroyed + for sam_site, count in sams_destroyed.items(): + dead_count = aaa.num_sam_dead(sam_site, count) + try: + modified_sam_site = db.SAM_CONVERT[sam_site] + except KeyError: + modified_sam_site = db.SAM_CONVERT[sam_site]['except'] + + try: + self.aa[modified_sam_site] = max( + self.aa[modified_sam_site] - dead_count, + 0 + ) + if self.aa[modified_sam_site] <= 0: + del self.aa[modified_sam_site] + except KeyError: + # if you destroy all launchers and the radar, it's enough to kill 2 sites. move along. + pass + def affect_strength(self, amount): self.strength += amount if self.strength > BASE_MAX_STRENGTH: diff --git a/theater/caucasus.py b/theater/caucasus.py index b4256a9..975d2bd 100644 --- a/theater/caucasus.py +++ b/theater/caucasus.py @@ -11,8 +11,8 @@ class CaucasusTheater(ConflictTheater): terrain = caucasus.Caucasus() overview_image = "caumap.gif" - reference_points = {(-317948.32727306, 635639.37385346): (278.5*2, 319*2), - (-355692.3067714, 617269.96285781): (263*2, 352*2), } + reference_points = {(-317948.32727306, 635639.37385346): (278.5*4, 319*4), + (-355692.3067714, 617269.96285781): (263*4, 352*4), } landmap = load_landmap("resources\\caulandmap.p") daytime_map = { "dawn": (6, 9), diff --git a/userdata/debriefing.py b/userdata/debriefing.py index 56b1567..19dfc86 100644 --- a/userdata/debriefing.py +++ b/userdata/debriefing.py @@ -171,7 +171,7 @@ def count_groups(groups: typing.List[UnitType]) -> typing.Dict[UnitType, int]: for group in static_groups: identifier = group.units[0].id - if identifier in self._dead_units: + if identifier in self._dead_units and group.units[0].type != 'big_smoke': logging.info("debriefing: found dead static {} ({})".format(str(group.name), identifier)) assert str(group.name)