From 2c1dc6a18d78f8348f015829830bc7c0a675847b Mon Sep 17 00:00:00 2001 From: David Pierron Date: Wed, 30 Sep 2020 13:34:41 +0200 Subject: [PATCH 01/43] removed useless link.cmd.sample file --- resources/scripts/plugins/link.cmd.sample | 29 ----------------------- 1 file changed, 29 deletions(-) delete mode 100644 resources/scripts/plugins/link.cmd.sample diff --git a/resources/scripts/plugins/link.cmd.sample b/resources/scripts/plugins/link.cmd.sample deleted file mode 100644 index e9c69ce7..00000000 --- a/resources/scripts/plugins/link.cmd.sample +++ /dev/null @@ -1,29 +0,0 @@ -rem this can be used to easily create hardlinks from your plugin development folder - -mklink mist.lua d:\dev\_VEAF\VEAF-Mission-Creation-Tools\src\scripts\community\mist.lua -mklink Moose.lua d:\dev\_VEAF\VEAF-Mission-Creation-Tools\src\scripts\community\Moose.lua -mklink CTLD.lua d:\dev\_VEAF\VEAF-Mission-Creation-Tools\src\scripts\community\CTLD.lua -mklink NIOD.lua d:\dev\_VEAF\VEAF-Mission-Creation-Tools\src\scripts\community\NIOD.lua -mklink WeatherMark.lua d:\dev\_VEAF\VEAF-Mission-Creation-Tools\src\scripts\community\WeatherMark.lua -mklink veaf.lua d:\dev\_VEAF\VEAF-Mission-Creation-Tools\src\scripts\veaf\veaf.lua -mklink dcsUnits.lua d:\dev\_VEAF\VEAF-Mission-Creation-Tools\src\scripts\veaf\dcsUnits.lua -mklink JTACAutoLase.lua d:\dev\_VEAF\VEAF-Mission-Creation-Tools\src\scripts\veaf\JTACAutoLase.lua -mklink veafAssets.lua d:\dev\_VEAF\VEAF-Mission-Creation-Tools\src\scripts\veaf\veafAssets.lua -mklink veafCarrierOperations.lua d:\dev\_VEAF\VEAF-Mission-Creation-Tools\src\scripts\veaf\veafCarrierOperations.lua -mklink veafCarrierOperations2.lua d:\dev\_VEAF\VEAF-Mission-Creation-Tools\src\scripts\veaf\veafCarrierOperations2.lua -mklink veafCasMission.lua d:\dev\_VEAF\VEAF-Mission-Creation-Tools\src\scripts\veaf\veafCasMission.lua -mklink veafCombatMission.lua d:\dev\_VEAF\VEAF-Mission-Creation-Tools\src\scripts\veaf\veafCombatMission.lua -mklink veafCombatZone.lua d:\dev\_VEAF\VEAF-Mission-Creation-Tools\src\scripts\veaf\veafCombatZone.lua -mklink veafGrass.lua d:\dev\_VEAF\VEAF-Mission-Creation-Tools\src\scripts\veaf\veafGrass.lua -mklink veafInterpreter.lua d:\dev\_VEAF\VEAF-Mission-Creation-Tools\src\scripts\veaf\veafInterpreter.lua -mklink veafMarkers.lua d:\dev\_VEAF\VEAF-Mission-Creation-Tools\src\scripts\veaf\veafMarkers.lua -mklink veafMove.lua d:\dev\_VEAF\VEAF-Mission-Creation-Tools\src\scripts\veaf\veafMove.lua -mklink veafNamedPoints.lua d:\dev\_VEAF\VEAF-Mission-Creation-Tools\src\scripts\veaf\veafNamedPoints.lua -mklink veafRadio.lua d:\dev\_VEAF\VEAF-Mission-Creation-Tools\src\scripts\veaf\veafRadio.lua -mklink veafRemote.lua d:\dev\_VEAF\VEAF-Mission-Creation-Tools\src\scripts\veaf\veafRemote.lua -mklink veafSecurity.lua d:\dev\_VEAF\VEAF-Mission-Creation-Tools\src\scripts\veaf\veafSecurity.lua -mklink veafShortcuts.lua d:\dev\_VEAF\VEAF-Mission-Creation-Tools\src\scripts\veaf\veafShortcuts.lua -mklink veafSpawn.lua d:\dev\_VEAF\VEAF-Mission-Creation-Tools\src\scripts\veaf\veafSpawn.lua -mklink veafTransportMission.lua d:\dev\_VEAF\VEAF-Mission-Creation-Tools\src\scripts\veaf\veafTransportMission.lua -mklink veafUnits.lua d:\dev\_VEAF\VEAF-Mission-Creation-Tools\src\scripts\veaf\veafUnits.lua -mklink missionConfig.lua d:\dev\_VEAF\VEAF-Mission-Creation-Tools\src\scripts\veaf\missionConfig.lua From 5807fbf89640a62addfc6c6445aa5a800bc3ca1f Mon Sep 17 00:00:00 2001 From: David Pierron Date: Wed, 30 Sep 2020 13:35:09 +0200 Subject: [PATCH 02/43] added a 'mkrelease' config for VS.Code --- .vscode/launch.json | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.vscode/launch.json b/.vscode/launch.json index 646c8768..fde0564f 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -14,6 +14,17 @@ "PYTHONPATH": ".;./pydcs" }, "preLaunchTask": "Prepare Environment" + }, + { + "name": "Python: Make Release", + "type": "python", + "request": "launch", + "program": "resources\\tools\\mkrelease.py", + "console": "integratedTerminal", + "env": { + "PYTHONPATH": ".;./pydcs" + }, + "preLaunchTask": "Prepare Environment" } ] } \ No newline at end of file From c77bfe9da21408119754411eaf5eaff14f5af3ea Mon Sep 17 00:00:00 2001 From: David Pierron Date: Fri, 9 Oct 2020 21:25:21 +0200 Subject: [PATCH 03/43] plugin base : inject mission configuration data --- game/operation/operation.py | 208 ++++++++++++++++++++++++++----- gen/aircraft.py | 9 +- gen/airsupportgen.py | 6 +- gen/armor.py | 3 +- gen/flights/ai_flight_planner.py | 3 +- gen/flights/flight.py | 1 + 6 files changed, 192 insertions(+), 38 deletions(-) diff --git a/game/operation/operation.py b/game/operation/operation.py index ecc82e51..66eddb78 100644 --- a/game/operation/operation.py +++ b/game/operation/operation.py @@ -14,7 +14,7 @@ from dcs.translation import String from dcs.triggers import TriggerStart from dcs.unittype import UnitType -from gen import Conflict, VisualGenerator +from gen import Conflict, VisualGenerator, FlightType from gen.aircraft import AIRCRAFT_DATA, AircraftConflictGenerator, FlightData from gen.airfields import AIRFIELD_DATA from gen.airsupportgen import AirSupport, AirSupportConflictGenerator @@ -259,6 +259,181 @@ class Operation: if self.game.settings.perf_smoke_gen: visualgen.generate() + luaData = {} + luaData["AircraftCarriers"] = {} + luaData["Tankers"] = {} + luaData["AWACs"] = {} + luaData["JTACs"] = {} + luaData["TargetPoints"] = {} + + self.assign_channels_to_flights(airgen.flights, + airsupportgen.air_support) + + kneeboard_generator = KneeboardGenerator(self.current_mission) + for dynamic_runway in groundobjectgen.runways.values(): + self.briefinggen.add_dynamic_runway(dynamic_runway) + + for tanker in airsupportgen.air_support.tankers: + self.briefinggen.add_tanker(tanker) + kneeboard_generator.add_tanker(tanker) + luaData["Tankers"][tanker.callsign] = { + "dcsGroupName": tanker.dcsGroupName, + "callsign": tanker.callsign, + "variant": tanker.variant, + "radio": tanker.freq.mhz, + "tacan": str(tanker.tacan.number) + tanker.tacan.band.name + } + + if self.is_awacs_enabled: + for awacs in airsupportgen.air_support.awacs: + self.briefinggen.add_awacs(awacs) + kneeboard_generator.add_awacs(awacs) + luaData["AWACs"][awacs.callsign] = { + "dcsGroupName": awacs.dcsGroupName, + "callsign": awacs.callsign, + "radio": awacs.freq.mhz + } + + for jtac in jtacs: + self.briefinggen.add_jtac(jtac) + kneeboard_generator.add_jtac(jtac) + luaData["JTACs"][jtac.callsign] = { + "dcsGroupName": jtac.dcsGroupName, + "callsign": jtac.callsign, + "zone": jtac.region, + "dcsUnit": jtac.unit_name, + "laserCode": jtac.code + } + + for flight in airgen.flights: + self.briefinggen.add_flight(flight) + kneeboard_generator.add_flight(flight) + if flight.friendly and flight.flight_type in [FlightType.ANTISHIP, FlightType.DEAD, FlightType.SEAD, FlightType.STRIKE]: + flightType = flight.flight_type.name + flightTarget = flight.targetPoint + if flightTarget: + flightTargetName = None + flightTargetType = None + if hasattr(flightTarget, 'obj_name'): + flightTargetName = flightTarget.obj_name + flightTargetType = flightType + f" TGT ({flightTarget.category})" + elif hasattr(flightTarget, 'name'): + flightTargetName = flightTarget.name + flightTargetType = flightType + " TGT (Airbase)" + luaData["TargetPoints"][flightTargetName] = { + "name": flightTargetName, + "type": flightTargetType, + "position": { "x": flightTarget.position.x, "y": flightTarget.position.y} + } + + + self.briefinggen.generate() + kneeboard_generator.generate() + + + # set a LUA table with data from Liberation that we want to set + # at the moment it contains Liberation's install path, and an overridable definition for the JTACAutoLase function + # later, we'll add data about the units and points having been generated, in order to facilitate the configuration of the plugin lua scripts + state_location = "[[" + os.path.abspath("state.json") + "]]" + lua = """ +-- setting configuration table +env.info("DCSLiberation|: setting configuration table") + +-- all data in this table is overridable. +dcsLiberation = {} + +-- the base location for state.json; if non-existent, it'll be replaced with LIBERATION_EXPORT_DIR, TEMP, or DCS working directory +dcsLiberation.installPath=""" + state_location + """ + +-- you can override dcsLiberation.JTACAutoLase to make it use your own function ; it will be called with these parameters : ({jtac.unit_name}, {jtac.code}, {smoke}, 'vehicle') for all JTACs +if ctld then + dcsLiberation.JTACAutoLase=ctld.JTACAutoLase +elseif JTACAutoLase then + dcsLiberation.JTACAutoLase=JTACAutoLase +end +""" + # Process the tankers + lua += """ + +-- list the tankers generated by Liberation +dcsLiberation.Tankers = { +""" + for key in luaData["Tankers"]: + data = luaData["Tankers"][key] + dcsGroupName= data["dcsGroupName"] + callsign = data["callsign"] + variant = data["variant"] + tacan = data["tacan"] + radio = data["radio"] + lua += f" {{dcsGroupName='{dcsGroupName}', callsign='{callsign}', variant='{variant}', tacan='{tacan}', radio='{radio}' }}, \n" + #lua += f" {{name='{dcsGroupName}', description='{callsign} ({variant})', information='Tacan:{tacan} Radio:{radio}' }}, \n" + lua += "}" + + # Process the AWACSes + lua += """ + +-- list the AWACs generated by Liberation +dcsLiberation.AWACs = { +""" + for key in luaData["AWACs"]: + data = luaData["AWACs"][key] + dcsGroupName= data["dcsGroupName"] + callsign = data["callsign"] + radio = data["radio"] + lua += f" {{dcsGroupName='{dcsGroupName}', callsign='{callsign}', radio='{radio}' }}, \n" + #lua += f" {{name='{dcsGroupName}', description='{callsign} (AWACS)', information='Radio:{radio}' }}, \n" + lua += "}" + + # Process the JTACs + lua += """ + +-- list the JTACs generated by Liberation +dcsLiberation.JTACs = { +""" + for key in luaData["JTACs"]: + data = luaData["JTACs"][key] + dcsGroupName= data["dcsGroupName"] + callsign = data["callsign"] + zone = data["zone"] + laserCode = data["laserCode"] + dcsUnit = data["dcsUnit"] + lua += f" {{dcsGroupName='{dcsGroupName}', callsign='{callsign}', zone='{zone}', laserCode='{laserCode}', dcsUnit='{dcsUnit}' }}, \n" + #lua += f" {{name='{dcsGroupName}', description='JTAC {callsign} ', information='Laser:{laserCode}', jtac={laserCode} }}, \n" + lua += "}" + + # Process the Target Points + lua += """ + +-- list the target points generated by Liberation +dcsLiberation.TargetPoints = { +""" + for key in luaData["TargetPoints"]: + data = luaData["TargetPoints"][key] + name = data["name"] + pointType = data["type"] + positionX = data["position"]["x"] + positionY = data["position"]["y"] + lua += f" {{name='{name}', pointType='{pointType}', positionX='{positionX}', positionY='{positionY}' }}, \n" + #lua += f" {{name='{pointType} {name}', point{{x={positionX}, z={positionY} }} }}, \n" + lua += "}" + + lua += """ + +-- list the airbases generated by Liberation +-- dcsLiberation.Airbases = {} + +-- list the aircraft carriers generated by Liberation +-- dcsLiberation.Carriers = {} + +-- later, we'll add more data to the table + +""" + + + trigger = TriggerStart(comment="Set DCS Liberation data") + trigger.add_action(DoScript(String(lua))) + self.current_mission.triggerrules.triggers.append(trigger) + # Inject Plugins Lua Scripts listOfPluginsScripts = [] plugin_file_path = Path("./resources/scripts/plugins/__plugins.lst") @@ -296,37 +471,6 @@ class Operation: trigger.add_action(DoScriptFile(fileref)) self.current_mission.triggerrules.triggers.append(trigger) - # set a LUA table with data from Liberation that we want to set - # at the moment it contains Liberation's install path, and an overridable definition for the JTACAutoLase function - # later, we'll add data about the units and points having been generated, in order to facilitate the configuration of the plugin lua scripts - state_location = "[[" + os.path.abspath("state.json") + "]]" - lua = """ - -- setting configuration table - env.info("DCSLiberation|: setting configuration table") - - -- all data in this table is overridable. - dcsLiberation = {} - - -- the base location for state.json; if non-existent, it'll be replaced with LIBERATION_EXPORT_DIR, TEMP, or DCS working directory - dcsLiberation.installPath=""" + state_location + """ - - -- you can override dcsLiberation.JTACAutoLase to make it use your own function ; it will be called with these parameters : ({jtac.unit_name}, {jtac.code}, {smoke}, 'vehicle') for all JTACs - if ctld then - dcsLiberation.JTACAutoLase=ctld.JTACAutoLase - elseif JTACAutoLase then - dcsLiberation.JTACAutoLase=JTACAutoLase - end - - -- later, we'll add more data to the table - --dcsLiberation.POIs = {} - --dcsLiberation.BASEs = {} - --dcsLiberation.JTACs = {} - """ - - trigger = TriggerStart(comment="Set DCS Liberation data") - trigger.add_action(DoScript(String(lua))) - self.current_mission.triggerrules.triggers.append(trigger) - # Inject DCS-Liberation script if not done already in the plugins if not "dcs_liberation.lua" in listOfPluginsScripts : # don't load the script twice trigger = TriggerStart(comment="Load DCS Liberation script") diff --git a/gen/aircraft.py b/gen/aircraft.py index cbb028fe..08491d42 100644 --- a/gen/aircraft.py +++ b/gen/aircraft.py @@ -238,11 +238,14 @@ class FlightData: #: Map of radio frequencies to their assigned radio and channel, if any. frequency_to_channel_map: Dict[RadioFrequency, ChannelAssignment] + #: Data concerning the target of a CAS/Strike/SEAD flight, or None else + targetPoint = None + def __init__(self, flight_type: FlightType, units: List[FlyingUnit], size: int, friendly: bool, departure_delay: int, departure: RunwayData, arrival: RunwayData, divert: Optional[RunwayData], waypoints: List[FlightWaypoint], - intra_flight_channel: RadioFrequency) -> None: + intra_flight_channel: RadioFrequency, targetPoint: Optional) -> None: self.flight_type = flight_type self.units = units self.size = size @@ -255,6 +258,7 @@ class FlightData: self.intra_flight_channel = intra_flight_channel self.frequency_to_channel_map = {} self.callsign = create_group_callsign_from_unit(self.units[0]) + self.targetPoint = targetPoint @property def client_units(self) -> List[FlyingUnit]: @@ -779,7 +783,8 @@ class AircraftConflictGenerator: divert=None, # Waypoints are added later, after they've had their TOTs set. waypoints=[], - intra_flight_channel=channel + intra_flight_channel=channel, + targetPoint=flight.targetPoint, )) # Special case so Su 33 carrier take off diff --git a/gen/airsupportgen.py b/gen/airsupportgen.py index 8a98dba7..97aeea1f 100644 --- a/gen/airsupportgen.py +++ b/gen/airsupportgen.py @@ -30,6 +30,7 @@ AWACS_ALT = 13000 @dataclass class AwacsInfo: """AWACS information for the kneeboard.""" + dcsGroupName: str callsign: str freq: RadioFrequency @@ -37,6 +38,7 @@ class AwacsInfo: @dataclass class TankerInfo: """Tanker information for the kneeboard.""" + dcsGroupName: str callsign: str variant: str freq: RadioFrequency @@ -116,7 +118,7 @@ class AirSupportConflictGenerator: tanker_group.points[0].tasks.append(SetInvisibleCommand(True)) tanker_group.points[0].tasks.append(SetImmortalCommand(True)) - self.air_support.tankers.append(TankerInfo(callsign, variant, freq, tacan)) + self.air_support.tankers.append(TankerInfo(str(tanker_group.name), callsign, variant, freq, tacan)) if is_awacs_enabled: try: @@ -138,6 +140,6 @@ class AirSupportConflictGenerator: awacs_flight.points[0].tasks.append(SetImmortalCommand(True)) self.air_support.awacs.append(AwacsInfo( - callsign_for_support_unit(awacs_flight), freq)) + str(awacs_flight.name), callsign_for_support_unit(awacs_flight), freq)) except: print("No AWACS for faction") \ No newline at end of file diff --git a/gen/armor.py b/gen/armor.py index 426dc05a..b6133b6b 100644 --- a/gen/armor.py +++ b/gen/armor.py @@ -54,6 +54,7 @@ RANDOM_OFFSET_ATTACK = 250 @dataclass(frozen=True) class JtacInfo: """JTAC information.""" + dcsGroupName: str unit_name: str callsign: str region: str @@ -158,7 +159,7 @@ class GroundConflictGenerator: frontline = f"Frontline {self.conflict.from_cp.name}/{self.conflict.to_cp.name}" # Note: Will need to change if we ever add ground based JTAC. callsign = callsign_for_support_unit(jtac) - self.jtacs.append(JtacInfo(n, callsign, frontline, str(code))) + self.jtacs.append(JtacInfo(str(jtac.name), n, callsign, frontline, str(code))) def gen_infantry_group_for_group(self, group, is_player, side:Country, forward_heading): diff --git a/gen/flights/ai_flight_planner.py b/gen/flights/ai_flight_planner.py index 09be3773..561f359c 100644 --- a/gen/flights/ai_flight_planner.py +++ b/gen/flights/ai_flight_planner.py @@ -205,6 +205,7 @@ class PackageBuilder: airfield, aircraft = assignment flight = Flight(aircraft, plan.num_aircraft, airfield, plan.task) self.package.add_flight(flight) + flight.targetPoint = location return True def build(self) -> Package: @@ -217,7 +218,7 @@ class PackageBuilder: for flight in flights: self.global_inventory.return_from_flight(flight) self.package.remove_flight(flight) - + flight.targetPoint = None class ObjectiveFinder: """Identifies potential objectives for the mission planner.""" diff --git a/gen/flights/flight.py b/gen/flights/flight.py index 0c972723..cef0987d 100644 --- a/gen/flights/flight.py +++ b/gen/flights/flight.py @@ -132,6 +132,7 @@ class Flight: preset_loadout_name = "" start_type = "Runway" group = False # Contains DCS Mission group data after mission has been generated + targetPoint = None # Contains either None or a Strike/SEAD target point location # How long before this flight should take off scheduled_in = 0 From d22943d7557efbcdcc62f0609298b59ef3db687c Mon Sep 17 00:00:00 2001 From: David Pierron Date: Mon, 12 Oct 2020 17:27:13 +0200 Subject: [PATCH 04/43] added a customizable plugin system - the base LUA functionality has been implemented as a mandatory plugin - the jtacautolase functionality has been implemented as a plugin - added a VEAF framework plugin The plugins have GUI elements in the Settings window. --- .gitignore | 3 +- .gitmodules | 3 + game/operation/operation.py | 98 +++++++----------- game/settings.py | 3 + gen/flights/ai_flight_planner.py | 4 +- plugin/__init__.py | 10 ++ .../base}/dcs_liberation.lua | 0 {resources/scripts => plugin/base}/json.lua | 0 .../scripts => plugin/base}/mist_4_3_74.lua | 0 plugin/base_plugin.py | 41 ++++++++ .../custom}/__plugins.lst.sample | 0 .../jtacautolase}/JTACAutoLase.lua | 0 plugin/jtacautolase_plugin.py | 80 ++++++++++++++ plugin/liberation_plugin.py | 20 ++++ plugin/veaf | 1 + plugin/veaf_plugin.py | 90 ++++++++++++++++ qt_ui/uiconstants.py | 2 + qt_ui/windows/settings/QSettingsWindow.py | 40 ++++++- resources/ui/misc/light/plugins.png | Bin 0 -> 1268 bytes resources/ui/misc/light/pluginsoptions.png | Bin 0 -> 1345 bytes 20 files changed, 330 insertions(+), 65 deletions(-) create mode 100644 plugin/__init__.py rename {resources/scripts => plugin/base}/dcs_liberation.lua (100%) rename {resources/scripts => plugin/base}/json.lua (100%) rename {resources/scripts => plugin/base}/mist_4_3_74.lua (100%) create mode 100644 plugin/base_plugin.py rename {resources/scripts/plugins => plugin/custom}/__plugins.lst.sample (100%) rename {resources/scripts => plugin/jtacautolase}/JTACAutoLase.lua (100%) create mode 100644 plugin/jtacautolase_plugin.py create mode 100644 plugin/liberation_plugin.py create mode 160000 plugin/veaf create mode 100644 plugin/veaf_plugin.py create mode 100644 resources/ui/misc/light/plugins.png create mode 100644 resources/ui/misc/light/pluginsoptions.png diff --git a/.gitignore b/.gitignore index 1bf595f6..f058e01f 100644 --- a/.gitignore +++ b/.gitignore @@ -21,4 +21,5 @@ logs/ qt_ui/logs/liberation.log *.psd -resources/scripts/plugins/* +plugin/custom/__plugins.lst +plugin/custom/*.lua diff --git a/.gitmodules b/.gitmodules index d8db9cf5..e4041d5d 100644 --- a/.gitmodules +++ b/.gitmodules @@ -2,3 +2,6 @@ path = pydcs url = https://github.com/pydcs/dcs branch = master +[submodule "plugin/veaf"] + path = plugin/veaf + url = https://github.com/VEAF/dcs-liberation-veaf-framework diff --git a/game/operation/operation.py b/game/operation/operation.py index 66eddb78..b537395e 100644 --- a/game/operation/operation.py +++ b/game/operation/operation.py @@ -31,7 +31,7 @@ from gen.triggergen import TRIGGER_RADIUS_MEDIUM, TriggersGenerator from theater import ControlPoint from .. import db from ..debriefing import Debriefing - +from plugin import BasePlugin, INSTALLED_PLUGINS class Operation: attackers_starting_position = None # type: db.StartingPosition @@ -75,6 +75,7 @@ class Operation: self.departure_cp = departure_cp self.to_cp = to_cp self.is_quick = False + self.listOfPluginsScripts = [] def units_of(self, country_name: str) -> List[UnitType]: return [] @@ -133,6 +134,36 @@ class Operation: else: self.defenders_starting_position = None + def injectLuaTrigger(self, luascript, comment = "LUA script"): + trigger = TriggerStart(comment=comment) + trigger.add_action(DoScript(String(luascript))) + self.current_mission.triggerrules.triggers.append(trigger) + + def bypassPluginScript(self, pluginName, scriptFileMnemonic): + self.listOfPluginsScripts.append(scriptFileMnemonic) + + def injectPluginScript(self, pluginName, scriptFile, scriptFileMnemonic): + if not scriptFileMnemonic in self.listOfPluginsScripts: + self.listOfPluginsScripts.append(scriptFileMnemonic) + + if pluginName == None: + pluginName = "custom" + plugin_path = Path("./plugin",pluginName) + + if scriptFile != None: + scriptFile_path = Path(plugin_path, scriptFile) + if scriptFile_path.exists(): + trigger = TriggerStart(comment="Load " + scriptFileMnemonic) + filename = scriptFile_path.resolve() + fileref = self.current_mission.map_resource.add_resource_file(filename) + trigger.add_action(DoScriptFile(fileref)) + self.current_mission.triggerrules.triggers.append(trigger) + else: + logging.error(f"Cannot find script file {scriptFile} for plugin {pluginName}") + + else: + logging.debug(f"Skipping script file {scriptFile} for plugin {pluginName}") + def generate(self): radio_registry = RadioRegistry() tacan_registry = TacanRegistry() @@ -434,67 +465,12 @@ dcsLiberation.TargetPoints = { trigger.add_action(DoScript(String(lua))) self.current_mission.triggerrules.triggers.append(trigger) - # Inject Plugins Lua Scripts - listOfPluginsScripts = [] - plugin_file_path = Path("./resources/scripts/plugins/__plugins.lst") - if plugin_file_path.exists(): - for line in plugin_file_path.read_text().splitlines(): - name = line.strip() - if not name.startswith( '#' ): - trigger = TriggerStart(comment="Load " + name) - listOfPluginsScripts.append(name) - fileref = self.current_mission.map_resource.add_resource_file("./resources/scripts/plugins/" + name) - trigger.add_action(DoScriptFile(fileref)) - self.current_mission.triggerrules.triggers.append(trigger) - else: - logging.info( - f"Not loading plugins, {plugin_file_path} does not exist") + # Inject Plugins Lua Scripts and data + self.listOfPluginsScripts = [] - # Inject Mist Script if not done already in the plugins - if not "mist.lua" in listOfPluginsScripts and not "mist_4_3_74.lua" in listOfPluginsScripts: # don't load the script twice - trigger = TriggerStart(comment="Load Mist Lua framework") - fileref = self.current_mission.map_resource.add_resource_file("./resources/scripts/mist_4_3_74.lua") - trigger.add_action(DoScriptFile(fileref)) - self.current_mission.triggerrules.triggers.append(trigger) - - # Inject JSON library if not done already in the plugins - if not "json.lua" in listOfPluginsScripts : # don't load the script twice - trigger = TriggerStart(comment="Load JSON Lua library") - fileref = self.current_mission.map_resource.add_resource_file("./resources/scripts/json.lua") - trigger.add_action(DoScriptFile(fileref)) - self.current_mission.triggerrules.triggers.append(trigger) - - # Inject Ciribob's JTACAutoLase if not done already in the plugins - if not "JTACAutoLase.lua" in listOfPluginsScripts : # don't load the script twice - trigger = TriggerStart(comment="Load JTACAutoLase.lua script") - fileref = self.current_mission.map_resource.add_resource_file("./resources/scripts/JTACAutoLase.lua") - trigger.add_action(DoScriptFile(fileref)) - self.current_mission.triggerrules.triggers.append(trigger) - - # Inject DCS-Liberation script if not done already in the plugins - if not "dcs_liberation.lua" in listOfPluginsScripts : # don't load the script twice - trigger = TriggerStart(comment="Load DCS Liberation script") - fileref = self.current_mission.map_resource.add_resource_file("./resources/scripts/dcs_liberation.lua") - trigger.add_action(DoScriptFile(fileref)) - self.current_mission.triggerrules.triggers.append(trigger) - - # add a configuration for JTACAutoLase and start lasing for all JTACs - smoke = "true" - if hasattr(self.game.settings, "jtac_smoke_on"): - if not self.game.settings.jtac_smoke_on: - smoke = "false" - - lua = """ - -- setting and starting JTACs - env.info("DCSLiberation|: setting and starting JTACs") - """ - - for jtac in jtacs: - lua += f"if dcsLiberation.JTACAutoLase then dcsLiberation.JTACAutoLase('{jtac.unit_name}', {jtac.code}, {smoke}, 'vehicle') end\n" - - trigger = TriggerStart(comment="Start JTACs") - trigger.add_action(DoScript(String(lua))) - self.current_mission.triggerrules.triggers.append(trigger) + for plugin in INSTALLED_PLUGINS: + plugin.injectScripts(self) + plugin.injectConfiguration(self) self.assign_channels_to_flights(airgen.flights, airsupportgen.air_support) diff --git a/game/settings.py b/game/settings.py index 4566ad0f..5d0d5c91 100644 --- a/game/settings.py +++ b/game/settings.py @@ -40,4 +40,7 @@ class Settings: self.perf_culling = False self.perf_culling_distance = 100 + # LUA Plugins system + self.plugins = {} + diff --git a/gen/flights/ai_flight_planner.py b/gen/flights/ai_flight_planner.py index 561f359c..03fe6d32 100644 --- a/gen/flights/ai_flight_planner.py +++ b/gen/flights/ai_flight_planner.py @@ -205,7 +205,7 @@ class PackageBuilder: airfield, aircraft = assignment flight = Flight(aircraft, plan.num_aircraft, airfield, plan.task) self.package.add_flight(flight) - flight.targetPoint = location + flight.targetPoint = self.package.target return True def build(self) -> Package: @@ -218,7 +218,7 @@ class PackageBuilder: for flight in flights: self.global_inventory.return_from_flight(flight) self.package.remove_flight(flight) - flight.targetPoint = None + flight.targetPoint = None class ObjectiveFinder: """Identifies potential objectives for the mission planner.""" diff --git a/plugin/__init__.py b/plugin/__init__.py new file mode 100644 index 00000000..c3cd6fb2 --- /dev/null +++ b/plugin/__init__.py @@ -0,0 +1,10 @@ +from .base_plugin import BasePlugin +from .veaf_plugin import VeafPlugin +from .jtacautolase_plugin import JtacAutolasePlugin +from .liberation_plugin import LiberationPlugin + +INSTALLED_PLUGINS=[ + VeafPlugin(), + JtacAutolasePlugin(), + LiberationPlugin() + ] \ No newline at end of file diff --git a/resources/scripts/dcs_liberation.lua b/plugin/base/dcs_liberation.lua similarity index 100% rename from resources/scripts/dcs_liberation.lua rename to plugin/base/dcs_liberation.lua diff --git a/resources/scripts/json.lua b/plugin/base/json.lua similarity index 100% rename from resources/scripts/json.lua rename to plugin/base/json.lua diff --git a/resources/scripts/mist_4_3_74.lua b/plugin/base/mist_4_3_74.lua similarity index 100% rename from resources/scripts/mist_4_3_74.lua rename to plugin/base/mist_4_3_74.lua diff --git a/plugin/base_plugin.py b/plugin/base_plugin.py new file mode 100644 index 00000000..c2a850e2 --- /dev/null +++ b/plugin/base_plugin.py @@ -0,0 +1,41 @@ +from PySide2.QtCore import QSize, Qt, QItemSelectionModel, QPoint +from PySide2.QtWidgets import QLabel, QDialog, QGridLayout, QListView, QStackedLayout, QComboBox, QWidget, \ + QAbstractItemView, QPushButton, QGroupBox, QCheckBox, QVBoxLayout, QSpinBox + +class BasePlugin(): + nameInUI:str = "Base plugin" + nameInSettings:str = "plugin.base" + enabledDefaultValue:bool = False + + def __init__(self): + self.uiWidget: QCheckBox = None + self.enabled = self.enabledDefaultValue + self.settings = None + + def setupUI(self, settingsWindow, row:int): + self.settings = settingsWindow.game.settings + + if not self.nameInSettings in self.settings.plugins: + self.settings.plugins[self.nameInSettings] = self.enabledDefaultValue + + self.uiWidget = QCheckBox() + self.uiWidget.setChecked(self.settings.plugins[self.nameInSettings]) + self.uiWidget.toggled.connect(lambda: self.applySetting(settingsWindow)) + + settingsWindow.pluginsGroupLayout.addWidget(QLabel(self.nameInUI), row, 0) + settingsWindow.pluginsGroupLayout.addWidget(self.uiWidget, row, 1, Qt.AlignRight) + + def applySetting(self, settingsWindow): + self.settings.plugins[self.nameInSettings] = self.uiWidget.isChecked() + self.enabled = self.settings.plugins[self.nameInSettings] + + def injectScripts(self, operation): + self.settings = operation.game.settings + return self.isEnabled() + + def injectConfiguration(self, operation): + self.settings = operation.game.settings + return self.isEnabled() + + def isEnabled(self) -> bool: + return self.settings != None and self.settings.plugins[self.nameInSettings] diff --git a/resources/scripts/plugins/__plugins.lst.sample b/plugin/custom/__plugins.lst.sample similarity index 100% rename from resources/scripts/plugins/__plugins.lst.sample rename to plugin/custom/__plugins.lst.sample diff --git a/resources/scripts/JTACAutoLase.lua b/plugin/jtacautolase/JTACAutoLase.lua similarity index 100% rename from resources/scripts/JTACAutoLase.lua rename to plugin/jtacautolase/JTACAutoLase.lua diff --git a/plugin/jtacautolase_plugin.py b/plugin/jtacautolase_plugin.py new file mode 100644 index 00000000..d8becf03 --- /dev/null +++ b/plugin/jtacautolase_plugin.py @@ -0,0 +1,80 @@ +from dcs.triggers import TriggerStart +from PySide2.QtCore import QSize, Qt, QItemSelectionModel, QPoint +from PySide2.QtWidgets import QLabel, QDialog, QGridLayout, QListView, QStackedLayout, QComboBox, QWidget, \ + QAbstractItemView, QPushButton, QGroupBox, QCheckBox, QVBoxLayout, QSpinBox +from .base_plugin import BasePlugin + +class JtacAutolasePlugin(BasePlugin): + nameInUI:str = "JTAC Autolase" + nameInSettings:str = "plugin.jtacAutolase" + enabledDefaultValue:bool = True + + #Allow spawn option + nameInUI_useSmoke:str = "JTACs use smoke" + nameInSettings_useSmoke:str = "plugin.jtacAutolase.useSmoke" + + def setupUI(self, settingsWindow, row:int): + # call the base method to add the plugin selection checkbox + super().setupUI(settingsWindow, row) + + if settingsWindow.pluginsOptionsPageLayout: + self.optionsGroup = QGroupBox(self.nameInUI) + optionsGroupLayout = QGridLayout(); + optionsGroupLayout.setAlignment(Qt.AlignTop) + self.optionsGroup.setLayout(optionsGroupLayout) + settingsWindow.pluginsOptionsPageLayout.addWidget(self.optionsGroup) + + # JTAC use smoke + if not self.nameInSettings_useSmoke in self.settings.plugins: + self.settings.plugins[self.nameInSettings_useSmoke] = True + + self.uiWidget_useSmoke = QCheckBox() + self.uiWidget_useSmoke.setChecked(self.settings.plugins[self.nameInSettings_useSmoke]) + self.uiWidget_useSmoke.toggled.connect(lambda: self.applySetting(settingsWindow)) + + optionsGroupLayout.addWidget(QLabel(self.nameInUI_useSmoke), 0, 0) + optionsGroupLayout.addWidget(self.uiWidget_useSmoke, 0, 1, Qt.AlignRight) + + # disable or enable the UI in the plugins special page + self.enableOptionsGroup() + + def enableOptionsGroup(self): + pluginEnabled = self.uiWidget.isChecked() + self.optionsGroup.setEnabled(pluginEnabled) + + def applySetting(self, settingsWindow): + # call the base method to apply the plugin selection checkbox value + super().applySetting(settingsWindow) + + # save the "use smoke" option + self.settings.plugins[self.nameInSettings_useSmoke] = self.uiWidget_useSmoke.isChecked() + + # disable or enable the UI in the plugins special page + self.enableOptionsGroup() + + def injectScripts(self, operation): + if super().injectScripts(operation): + operation.injectPluginScript("jtacautolase", "JTACAutoLase.lua", "jtacautolase") + + def injectConfiguration(self, operation): + if super().injectConfiguration(operation): + + # add a configuration for JTACAutoLase and start lasing for all JTACs + smoke = "local smoke = false" + if self.settings.plugins[self.nameInSettings_useSmoke]: + smoke = "local smoke = true" + + lua = smoke + """ + + -- setting and starting JTACs + env.info("DCSLiberation|: setting and starting JTACs") + + for _, jtac in pairs(dcsLiberation.JTACs) do + if dcsLiberation.JTACAutoLase then + dcsLiberation.JTACAutoLase(jtac.dcsUnit, jtac.code, smoke, 'vehicle') + end + end + """ + + operation.injectLuaTrigger(lua, "Setting and starting JTACs") + diff --git a/plugin/liberation_plugin.py b/plugin/liberation_plugin.py new file mode 100644 index 00000000..e9a01a39 --- /dev/null +++ b/plugin/liberation_plugin.py @@ -0,0 +1,20 @@ +from .base_plugin import BasePlugin + +class LiberationPlugin(BasePlugin): + nameInUI:str = "Liberation script" + nameInSettings:str = "plugin.liberation" + enabledDefaultValue:bool = True + + def setupUI(self, settingsWindow, row:int): + # Don't setup any UI, this plugin is mandatory + pass + + def injectScripts(self, operation): + if super().injectScripts(operation): + operation.injectPluginScript("base", "mist_4_3_74.lua", "mist") + operation.injectPluginScript("base", "json.lua", "json") + operation.injectPluginScript("base", "dcs_liberation.lua", "liberation") + + def injectConfiguration(self, operation): + if super().injectConfiguration(operation): + pass diff --git a/plugin/veaf b/plugin/veaf new file mode 160000 index 00000000..219cdffe --- /dev/null +++ b/plugin/veaf @@ -0,0 +1 @@ +Subproject commit 219cdffef087660fe448a41e1f187c4856e9d80f diff --git a/plugin/veaf_plugin.py b/plugin/veaf_plugin.py new file mode 100644 index 00000000..2e3b0531 --- /dev/null +++ b/plugin/veaf_plugin.py @@ -0,0 +1,90 @@ +from PySide2.QtCore import QSize, Qt, QItemSelectionModel, QPoint +from PySide2.QtWidgets import QLabel, QDialog, QGridLayout, QListView, QStackedLayout, QComboBox, QWidget, \ + QAbstractItemView, QPushButton, QGroupBox, QCheckBox, QVBoxLayout, QSpinBox +from .base_plugin import BasePlugin + +class VeafPlugin(BasePlugin): + nameInUI:str = "VEAF framework" + nameInSettings:str = "plugin.veaf" + enabledDefaultValue:bool = False + + #Allow spawn option + nameInUI_allowSpawn:str = "Allow units spawn via markers and CTLD (not implemented yet)" + nameInSettings_allowSpawn:str = "plugin.veaf.allowSpawn" + + def setupUI(self, settingsWindow, row:int): + # call the base method to add the plugin selection checkbox + super().setupUI(settingsWindow, row) + + if settingsWindow.pluginsOptionsPageLayout: + self.optionsGroup = QGroupBox(self.nameInUI) + optionsGroupLayout = QGridLayout(); + optionsGroupLayout.setAlignment(Qt.AlignTop) + self.optionsGroup.setLayout(optionsGroupLayout) + settingsWindow.pluginsOptionsPageLayout.addWidget(self.optionsGroup) + + # allow spawn of objects + if not self.nameInSettings_allowSpawn in self.settings.plugins: + self.settings.plugins[self.nameInSettings_allowSpawn] = True + + self.uiWidget_allowSpawn = QCheckBox() + self.uiWidget_allowSpawn.setChecked(self.settings.plugins[self.nameInSettings_allowSpawn]) + self.uiWidget_allowSpawn.setEnabled(False) + self.uiWidget_allowSpawn.toggled.connect(lambda: self.applySetting(settingsWindow)) + + optionsGroupLayout.addWidget(QLabel(self.nameInUI_allowSpawn), 0, 0) + optionsGroupLayout.addWidget(self.uiWidget_allowSpawn, 0, 1, Qt.AlignRight) + + # disable or enable the UI in the plugins special page + self.enableOptionsGroup() + + def enableOptionsGroup(self): + pluginEnabled = self.uiWidget.isChecked() + self.optionsGroup.setEnabled(pluginEnabled) + + def applySetting(self, settingsWindow): + # call the base method to apply the plugin selection checkbox value + super().applySetting(settingsWindow) + + # save the "allow spawn" option + self.settings.plugins[self.nameInSettings_allowSpawn] = self.uiWidget_allowSpawn.isChecked() + + # disable or enable the UI in the plugins special page + self.enableOptionsGroup() + + def injectScripts(self, operation): + if super().injectScripts(operation): + # bypass JTACAutoLase + operation.bypassPluginScript("veaf", "jtacautolase") + + # inject the required scripts + operation.injectPluginScript("veaf", "src\\scripts\\mist.lua", "mist") + operation.injectPluginScript("veaf", "src\\scripts\\Moose.lua", "moose") + operation.injectPluginScript("veaf", "src\\scripts\\CTLD.lua", "ctld") + operation.injectPluginScript("veaf", "src\\scripts\\NIOD.lua", "niod") + operation.injectPluginScript("veaf", "src\\scripts\\WeatherMark.lua", "weathermark") + operation.injectPluginScript("veaf", "src\\scripts\\veaf.lua", "veaf") + operation.injectPluginScript("veaf", "src\\scripts\\dcsUnits.lua", "dcsunits") + operation.injectPluginScript("veaf", "src\\scripts\\veafAssets.lua", "veafassets") + operation.injectPluginScript("veaf", "src\\scripts\\veafCarrierOperations.lua", "veafcarrieroperations") + operation.injectPluginScript("veaf", "src\\scripts\\veafCasMission.lua", "veafcasmission") + operation.injectPluginScript("veaf", "src\\scripts\\veafCombatMission.lua", "veafcombatmission") + operation.injectPluginScript("veaf", "src\\scripts\\veafCombatZone.lua", "veafcombatzone") + operation.injectPluginScript("veaf", "src\\scripts\\veafGrass.lua", "veafgrass") + operation.injectPluginScript("veaf", "src\\scripts\\veafInterpreter.lua", "veafinterpreter") + operation.injectPluginScript("veaf", "src\\scripts\\veafMarkers.lua", "veafmarkers") + operation.injectPluginScript("veaf", "src\\scripts\\veafMove.lua", "veafmove") + operation.injectPluginScript("veaf", "src\\scripts\\veafNamedPoints.lua", "veafnamedpoints") + operation.injectPluginScript("veaf", "src\\scripts\\veafRadio.lua", "veafradio") + operation.injectPluginScript("veaf", "src\\scripts\\veafRemote.lua", "veafremote") + operation.injectPluginScript("veaf", "src\\scripts\\veafSecurity.lua", "veafsecurity") + operation.injectPluginScript("veaf", "src\\scripts\\veafShortcuts.lua", "veafshortcuts") + operation.injectPluginScript("veaf", "src\\scripts\\veafSpawn.lua", "veafspawn") + operation.injectPluginScript("veaf", "src\\scripts\\veafTransportMission.lua", "veaftransportmission") + operation.injectPluginScript("veaf", "src\\scripts\\veafUnits.lua", "veafunits") + + + def injectConfiguration(self, operation): + if super().injectConfiguration(operation): + operation.injectPluginScript("veaf", "src\\config\\missionConfig.lua", "missionconfig") + diff --git a/qt_ui/uiconstants.py b/qt_ui/uiconstants.py index 5c97dc72..5c831c1e 100644 --- a/qt_ui/uiconstants.py +++ b/qt_ui/uiconstants.py @@ -99,6 +99,8 @@ def load_icons(): ICONS["Generator"] = QPixmap("./resources/ui/misc/"+get_theme_icons()+"/generator.png") ICONS["Missile"] = QPixmap("./resources/ui/misc/"+get_theme_icons()+"/missile.png") ICONS["Cheat"] = QPixmap("./resources/ui/misc/"+get_theme_icons()+"/cheat.png") + ICONS["Plugins"] = QPixmap("./resources/ui/misc/"+get_theme_icons()+"/plugins.png") + ICONS["PluginsOptions"] = QPixmap("./resources/ui/misc/"+get_theme_icons()+"/pluginsoptions.png") ICONS["TaskCAS"] = QPixmap("./resources/ui/tasks/cas.png") ICONS["TaskCAP"] = QPixmap("./resources/ui/tasks/cap.png") diff --git a/qt_ui/windows/settings/QSettingsWindow.py b/qt_ui/windows/settings/QSettingsWindow.py index 6ccdb226..82010be9 100644 --- a/qt_ui/windows/settings/QSettingsWindow.py +++ b/qt_ui/windows/settings/QSettingsWindow.py @@ -11,7 +11,7 @@ from game.game import Game from game.infos.information import Information from qt_ui.windows.GameUpdateSignal import GameUpdateSignal from qt_ui.windows.finances.QFinancesMenu import QHorizontalSeparationLine - +from plugin import BasePlugin, INSTALLED_PLUGINS class QSettingsWindow(QDialog): @@ -52,10 +52,22 @@ class QSettingsWindow(QDialog): cheat.setEditable(False) cheat.setSelectable(True) + plugins = QStandardItem("LUA Plugins") + plugins.setIcon(CONST.ICONS["Plugins"]) + plugins.setEditable(False) + plugins.setSelectable(True) + + pluginsOptions = QStandardItem("LUA Plugins Options") + pluginsOptions.setIcon(CONST.ICONS["PluginsOptions"]) + pluginsOptions.setEditable(False) + pluginsOptions.setSelectable(True) + self.categoryList.setIconSize(QSize(32, 32)) self.categoryModel.appendRow(difficulty) self.categoryModel.appendRow(generator) self.categoryModel.appendRow(cheat) + self.categoryModel.appendRow(plugins) + self.categoryModel.appendRow(pluginsOptions) self.categoryList.setSelectionBehavior(QAbstractItemView.SelectRows) self.categoryList.setModel(self.categoryModel) @@ -65,10 +77,13 @@ class QSettingsWindow(QDialog): self.initDifficultyLayout() self.initGeneratorLayout() self.initCheatLayout() + self.initPluginsLayout() self.right_layout.addWidget(self.difficultyPage) self.right_layout.addWidget(self.generatorPage) self.right_layout.addWidget(self.cheatPage) + self.right_layout.addWidget(self.pluginsPage) + self.right_layout.addWidget(self.pluginsOptionsPage) self.layout.addWidget(self.categoryList, 0, 0, 1, 1) self.layout.addLayout(self.right_layout, 0, 1, 5, 1) @@ -283,6 +298,29 @@ class QSettingsWindow(QDialog): self.moneyCheatBoxLayout.addWidget(btn, i/2, i%2) self.cheatLayout.addWidget(self.moneyCheatBox, 0, 0) + def initPluginsLayout(self): + self.pluginsOptionsPage = QWidget() + self.pluginsOptionsPageLayout = QVBoxLayout() + self.pluginsOptionsPageLayout.setAlignment(Qt.AlignTop) + self.pluginsOptionsPage.setLayout(self.pluginsOptionsPageLayout) + + self.pluginsPage = QWidget() + self.pluginsPageLayout = QVBoxLayout() + self.pluginsPageLayout.setAlignment(Qt.AlignTop) + self.pluginsPage.setLayout(self.pluginsPageLayout) + + self.pluginsGroup = QGroupBox("Plugins") + self.pluginsGroupLayout = QGridLayout(); + self.pluginsGroupLayout.setAlignment(Qt.AlignTop) + self.pluginsGroup.setLayout(self.pluginsGroupLayout) + + row:int = 0 + for plugin in INSTALLED_PLUGINS: + plugin.setupUI(self, row) + row = row + 1 + + self.pluginsPageLayout.addWidget(self.pluginsGroup) + def cheatLambda(self, amount): return lambda: self.cheatMoney(amount) diff --git a/resources/ui/misc/light/plugins.png b/resources/ui/misc/light/plugins.png new file mode 100644 index 0000000000000000000000000000000000000000..568d9e9581ed36753a5cfc9c0f431eb8c9aee38f GIT binary patch literal 1268 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 Date: Mon, 12 Oct 2020 19:49:39 +0200 Subject: [PATCH 05/43] multiple changes - load plugins when loading a game - moved plugins scripts to resources/plugins (for pyinstaller) - removed vanilla JTAC and JTAC_smoke options and settings GUI - call JtacAutolasePlugin in armor.py - made a dictionary of INSTALLED_PLUGINS - removed NIOD from the VEAF plugin --- .gitignore | 4 +-- .gitmodules | 3 +++ game/game.py | 6 +++++ game/operation/operation.py | 6 +++-- game/settings.py | 7 ++++-- gen/armor.py | 4 ++- plugin/__init__.py | 10 ++++---- plugin/base_plugin.py | 15 +++++++++--- plugin/jtacautolase_plugin.py | 17 ++++++++++++- plugin/liberation_plugin.py | 3 +++ plugin/veaf_plugin.py | 2 +- qt_ui/windows/settings/QSettingsWindow.py | 23 ++---------------- .../plugins}/base/dcs_liberation.lua | 0 {plugin => resources/plugins}/base/json.lua | 0 .../plugins}/base/mist_4_3_74.lua | 0 .../plugins}/custom/__plugins.lst.sample | 0 resources/plugins/doc/0.png | Bin 0 -> 25178 bytes resources/plugins/doc/1.png | Bin 0 -> 26777 bytes resources/plugins/doc/2.png | Bin 0 -> 19406 bytes .../plugins}/jtacautolase/JTACAutoLase.lua | 0 {plugin => resources/plugins}/veaf | 0 21 files changed, 61 insertions(+), 39 deletions(-) rename {plugin => resources/plugins}/base/dcs_liberation.lua (100%) rename {plugin => resources/plugins}/base/json.lua (100%) rename {plugin => resources/plugins}/base/mist_4_3_74.lua (100%) rename {plugin => resources/plugins}/custom/__plugins.lst.sample (100%) create mode 100644 resources/plugins/doc/0.png create mode 100644 resources/plugins/doc/1.png create mode 100644 resources/plugins/doc/2.png rename {plugin => resources/plugins}/jtacautolase/JTACAutoLase.lua (100%) rename {plugin => resources/plugins}/veaf (100%) diff --git a/.gitignore b/.gitignore index f058e01f..ec279d3d 100644 --- a/.gitignore +++ b/.gitignore @@ -21,5 +21,5 @@ logs/ qt_ui/logs/liberation.log *.psd -plugin/custom/__plugins.lst -plugin/custom/*.lua +resources/plugins/custom/__plugins.lst +resources/plugins/custom/*.lua diff --git a/.gitmodules b/.gitmodules index e4041d5d..4d2ad31a 100644 --- a/.gitmodules +++ b/.gitmodules @@ -5,3 +5,6 @@ [submodule "plugin/veaf"] path = plugin/veaf url = https://github.com/VEAF/dcs-liberation-veaf-framework +[submodule "resources/plugins/veaf"] + path = resources/plugins/veaf + url = https://github.com/VEAF/dcs-liberation-veaf-framework diff --git a/game/game.py b/game/game.py index 57fabfd6..67eeb200 100644 --- a/game/game.py +++ b/game/game.py @@ -28,6 +28,7 @@ from .event.event import Event, UnitsDeliveryEvent from .event.frontlineattack import FrontlineAttackEvent from .infos.information import Information from .settings import Settings +from plugin import INSTALLED_PLUGINS COMMISION_UNIT_VARIETY = 4 COMMISION_LIMITS_SCALE = 1.5 @@ -217,6 +218,11 @@ class Game: def on_load(self) -> None: ObjectiveDistanceCache.set_theater(self.theater) + + # set the settings in all plugins + for pluginName in INSTALLED_PLUGINS: + plugin = INSTALLED_PLUGINS[pluginName] + plugin.setSettings(self.settings) def pass_turn(self, no_action=False): logging.info("Pass turn") diff --git a/game/operation/operation.py b/game/operation/operation.py index b537395e..e42d7b14 100644 --- a/game/operation/operation.py +++ b/game/operation/operation.py @@ -148,7 +148,8 @@ class Operation: if pluginName == None: pluginName = "custom" - plugin_path = Path("./plugin",pluginName) + plugin_path = Path("./resources/plugins",pluginName) + logging.debug(f"plugin_path = {plugin_path}") if scriptFile != None: scriptFile_path = Path(plugin_path, scriptFile) @@ -468,7 +469,8 @@ dcsLiberation.TargetPoints = { # Inject Plugins Lua Scripts and data self.listOfPluginsScripts = [] - for plugin in INSTALLED_PLUGINS: + for pluginName in INSTALLED_PLUGINS: + plugin = INSTALLED_PLUGINS[pluginName] plugin.injectScripts(self) plugin.injectConfiguration(self) diff --git a/game/settings.py b/game/settings.py index 5d0d5c91..bb7754d4 100644 --- a/game/settings.py +++ b/game/settings.py @@ -1,3 +1,4 @@ +from plugin import INSTALLED_PLUGINS class Settings: @@ -24,8 +25,6 @@ class Settings: self.sams = True # Legacy parameter do not use self.cold_start = False # Legacy parameter do not use self.version = None - self.include_jtac_if_available = True - self.jtac_smoke_on = True # Performance oriented self.perf_red_alert_state = True @@ -42,5 +41,9 @@ class Settings: # LUA Plugins system self.plugins = {} + for pluginName in INSTALLED_PLUGINS: + plugin = INSTALLED_PLUGINS[pluginName] + plugin.setSettings(self) + diff --git a/gen/armor.py b/gen/armor.py index b6133b6b..21ae5a25 100644 --- a/gen/armor.py +++ b/gen/armor.py @@ -34,6 +34,7 @@ from gen.ground_forces.ai_ground_planner import ( from .callsigns import callsign_for_support_unit from .conflictgen import Conflict from .ground_forces.combat_stance import CombatStance +from plugin import INSTALLED_PLUGINS SPREAD_DISTANCE_FACTOR = 0.1, 0.3 SPREAD_DISTANCE_SIZE_FACTOR = 0.1 @@ -139,7 +140,8 @@ class GroundConflictGenerator: self.plan_action_for_groups(self.enemy_stance, enemy_groups, player_groups, self.conflict.heading - 90, self.conflict.to_cp, self.conflict.from_cp) # Add JTAC - if "has_jtac" in self.game.player_faction and self.game.player_faction["has_jtac"] and self.game.settings.include_jtac_if_available: + useJTAC = INSTALLED_PLUGINS and INSTALLED_PLUGINS["JtacAutolasePlugin"] and INSTALLED_PLUGINS["JtacAutolasePlugin"].isEnabled() + if "has_jtac" in self.game.player_faction and self.game.player_faction["has_jtac"] and useJTAC: n = "JTAC" + str(self.conflict.from_cp.id) + str(self.conflict.to_cp.id) code = 1688 - len(self.jtacs) diff --git a/plugin/__init__.py b/plugin/__init__.py index c3cd6fb2..2bbf459c 100644 --- a/plugin/__init__.py +++ b/plugin/__init__.py @@ -3,8 +3,8 @@ from .veaf_plugin import VeafPlugin from .jtacautolase_plugin import JtacAutolasePlugin from .liberation_plugin import LiberationPlugin -INSTALLED_PLUGINS=[ - VeafPlugin(), - JtacAutolasePlugin(), - LiberationPlugin() - ] \ No newline at end of file +INSTALLED_PLUGINS={ + "VeafPlugin": VeafPlugin(), + "JtacAutolasePlugin": JtacAutolasePlugin(), + "LiberationPlugin": LiberationPlugin(), +} diff --git a/plugin/base_plugin.py b/plugin/base_plugin.py index c2a850e2..4eb6e744 100644 --- a/plugin/base_plugin.py +++ b/plugin/base_plugin.py @@ -15,16 +15,18 @@ class BasePlugin(): def setupUI(self, settingsWindow, row:int): self.settings = settingsWindow.game.settings - if not self.nameInSettings in self.settings.plugins: - self.settings.plugins[self.nameInSettings] = self.enabledDefaultValue - self.uiWidget = QCheckBox() - self.uiWidget.setChecked(self.settings.plugins[self.nameInSettings]) + self.uiWidget.setChecked(self.isEnabled()) self.uiWidget.toggled.connect(lambda: self.applySetting(settingsWindow)) settingsWindow.pluginsGroupLayout.addWidget(QLabel(self.nameInUI), row, 0) settingsWindow.pluginsGroupLayout.addWidget(self.uiWidget, row, 1, Qt.AlignRight) + def setSettings(self, settings): + self.settings = settings + if not self.nameInSettings in self.settings.plugins: + self.settings.plugins[self.nameInSettings] = self.enabledDefaultValue + def applySetting(self, settingsWindow): self.settings.plugins[self.nameInSettings] = self.uiWidget.isChecked() self.enabled = self.settings.plugins[self.nameInSettings] @@ -38,4 +40,9 @@ class BasePlugin(): return self.isEnabled() def isEnabled(self) -> bool: + if not self.settings: + return False + + self.setSettings(self.settings) # create the necessary settings keys if needed + return self.settings != None and self.settings.plugins[self.nameInSettings] diff --git a/plugin/jtacautolase_plugin.py b/plugin/jtacautolase_plugin.py index d8becf03..8b0c316c 100644 --- a/plugin/jtacautolase_plugin.py +++ b/plugin/jtacautolase_plugin.py @@ -12,6 +12,7 @@ class JtacAutolasePlugin(BasePlugin): #Allow spawn option nameInUI_useSmoke:str = "JTACs use smoke" nameInSettings_useSmoke:str = "plugin.jtacAutolase.useSmoke" + enabledDefaultValue_useSmoke:bool = True def setupUI(self, settingsWindow, row:int): # call the base method to add the plugin selection checkbox @@ -42,6 +43,12 @@ class JtacAutolasePlugin(BasePlugin): pluginEnabled = self.uiWidget.isChecked() self.optionsGroup.setEnabled(pluginEnabled) + def setSettings(self, settings): + # call the base method + super().setSettings(settings) + if not self.nameInSettings_useSmoke in self.settings.plugins: + self.settings.plugins[self.nameInSettings_useSmoke] = self.enabledDefaultValue_useSmoke + def applySetting(self, settingsWindow): # call the base method to apply the plugin selection checkbox value super().applySetting(settingsWindow) @@ -61,7 +68,7 @@ class JtacAutolasePlugin(BasePlugin): # add a configuration for JTACAutoLase and start lasing for all JTACs smoke = "local smoke = false" - if self.settings.plugins[self.nameInSettings_useSmoke]: + if self.isUseSmoke(): smoke = "local smoke = true" lua = smoke + """ @@ -78,3 +85,11 @@ class JtacAutolasePlugin(BasePlugin): operation.injectLuaTrigger(lua, "Setting and starting JTACs") + def isUseSmoke(self) -> bool: + if not self.settings: + return False + + self.setSettings(self.settings) # create the necessary settings keys if needed + + return self.settings.plugins[self.nameInSettings_useSmoke] + diff --git a/plugin/liberation_plugin.py b/plugin/liberation_plugin.py index e9a01a39..ef97be3a 100644 --- a/plugin/liberation_plugin.py +++ b/plugin/liberation_plugin.py @@ -18,3 +18,6 @@ class LiberationPlugin(BasePlugin): def injectConfiguration(self, operation): if super().injectConfiguration(operation): pass + + def isEnabled(self) -> bool: + return True # mandatory plugin diff --git a/plugin/veaf_plugin.py b/plugin/veaf_plugin.py index 2e3b0531..c32cd86a 100644 --- a/plugin/veaf_plugin.py +++ b/plugin/veaf_plugin.py @@ -61,7 +61,7 @@ class VeafPlugin(BasePlugin): operation.injectPluginScript("veaf", "src\\scripts\\mist.lua", "mist") operation.injectPluginScript("veaf", "src\\scripts\\Moose.lua", "moose") operation.injectPluginScript("veaf", "src\\scripts\\CTLD.lua", "ctld") - operation.injectPluginScript("veaf", "src\\scripts\\NIOD.lua", "niod") + #operation.injectPluginScript("veaf", "src\\scripts\\NIOD.lua", "niod") operation.injectPluginScript("veaf", "src\\scripts\\WeatherMark.lua", "weathermark") operation.injectPluginScript("veaf", "src\\scripts\\veaf.lua", "veaf") operation.injectPluginScript("veaf", "src\\scripts\\dcsUnits.lua", "dcsunits") diff --git a/qt_ui/windows/settings/QSettingsWindow.py b/qt_ui/windows/settings/QSettingsWindow.py index 82010be9..88382ee0 100644 --- a/qt_ui/windows/settings/QSettingsWindow.py +++ b/qt_ui/windows/settings/QSettingsWindow.py @@ -183,28 +183,10 @@ class QSettingsWindow(QDialog): self.generate_marks.setChecked(self.game.settings.generate_marks) self.generate_marks.toggled.connect(self.applySettings) - - if not hasattr(self.game.settings, "include_jtac_if_available"): - self.game.settings.include_jtac_if_available = True - if not hasattr(self.game.settings, "jtac_smoke_on"): - self.game.settings.jtac_smoke_on= True - - self.include_jtac_if_available = QCheckBox() - self.include_jtac_if_available.setChecked(self.game.settings.include_jtac_if_available) - self.include_jtac_if_available.toggled.connect(self.applySettings) - - self.jtac_smoke_on = QCheckBox() - self.jtac_smoke_on.setChecked(self.game.settings.jtac_smoke_on) - self.jtac_smoke_on.toggled.connect(self.applySettings) - self.gameplayLayout.addWidget(QLabel("Use Supercarrier Module"), 0, 0) self.gameplayLayout.addWidget(self.supercarrier, 0, 1, Qt.AlignRight) self.gameplayLayout.addWidget(QLabel("Put Objective Markers on Map"), 1, 0) self.gameplayLayout.addWidget(self.generate_marks, 1, 1, Qt.AlignRight) - self.gameplayLayout.addWidget(QLabel("Include JTAC (If available)"), 2, 0) - self.gameplayLayout.addWidget(self.include_jtac_if_available, 2, 1, Qt.AlignRight) - self.gameplayLayout.addWidget(QLabel("Enable JTAC smoke markers"), 3, 0) - self.gameplayLayout.addWidget(self.jtac_smoke_on, 3, 1, Qt.AlignRight) self.performance = QGroupBox("Performance") self.performanceLayout = QGridLayout() @@ -315,7 +297,8 @@ class QSettingsWindow(QDialog): self.pluginsGroup.setLayout(self.pluginsGroupLayout) row:int = 0 - for plugin in INSTALLED_PLUGINS: + for pluginName in INSTALLED_PLUGINS: + plugin = INSTALLED_PLUGINS[pluginName] plugin.setupUI(self, row) row = row + 1 @@ -342,8 +325,6 @@ class QSettingsWindow(QDialog): self.game.settings.map_coalition_visibility = self.mapVisibiitySelection.currentData() self.game.settings.external_views_allowed = self.ext_views.isChecked() self.game.settings.generate_marks = self.generate_marks.isChecked() - self.game.settings.include_jtac_if_available = self.include_jtac_if_available.isChecked() - self.game.settings.jtac_smoke_on = self.jtac_smoke_on.isChecked() print(self.game.settings.map_coalition_visibility) diff --git a/plugin/base/dcs_liberation.lua b/resources/plugins/base/dcs_liberation.lua similarity index 100% rename from plugin/base/dcs_liberation.lua rename to resources/plugins/base/dcs_liberation.lua diff --git a/plugin/base/json.lua b/resources/plugins/base/json.lua similarity index 100% rename from plugin/base/json.lua rename to resources/plugins/base/json.lua diff --git a/plugin/base/mist_4_3_74.lua b/resources/plugins/base/mist_4_3_74.lua similarity index 100% rename from plugin/base/mist_4_3_74.lua rename to resources/plugins/base/mist_4_3_74.lua diff --git a/plugin/custom/__plugins.lst.sample b/resources/plugins/custom/__plugins.lst.sample similarity index 100% rename from plugin/custom/__plugins.lst.sample rename to resources/plugins/custom/__plugins.lst.sample diff --git a/resources/plugins/doc/0.png b/resources/plugins/doc/0.png new file mode 100644 index 0000000000000000000000000000000000000000..3320b90882796fdb32556783d84bd7ead617f9fd GIT binary patch literal 25178 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 gzBTRrd0`--8`CB(>Z{p$M-BMJL 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 LRb@(~UIzSMOpY6~ literal 0 HcmV?d00001 diff --git a/plugin/jtacautolase/JTACAutoLase.lua b/resources/plugins/jtacautolase/JTACAutoLase.lua similarity index 100% rename from plugin/jtacautolase/JTACAutoLase.lua rename to resources/plugins/jtacautolase/JTACAutoLase.lua diff --git a/plugin/veaf b/resources/plugins/veaf similarity index 100% rename from plugin/veaf rename to resources/plugins/veaf From 5463505787e4105ffc65c0e251388358a481e361 Mon Sep 17 00:00:00 2001 From: David Pierron Date: Mon, 12 Oct 2020 20:01:27 +0200 Subject: [PATCH 06/43] added documentation for plugins system --- resources/plugins/doc/plugins_readme.md | 58 +++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 resources/plugins/doc/plugins_readme.md diff --git a/resources/plugins/doc/plugins_readme.md b/resources/plugins/doc/plugins_readme.md new file mode 100644 index 00000000..41c90955 --- /dev/null +++ b/resources/plugins/doc/plugins_readme.md @@ -0,0 +1,58 @@ +# LUA Plugin system + +This plugin system was made for injecting LUA scripts in dcs-liberation missions. + +The resources for the plugins are stored in the `resources/plugins` folder ; each plugin has its own folder. + +## Standard plugins + +### The *base* plugin + +The *base* plugin contains the scripts that are going to be injected in every dcs-liberation missions. +It is mandatory. + +### The *JTACAutolase* plugin + +This plugin replaces the vanilla JTAC functionality in dcs-liberation. + +### The *VEAF framework* plugin + +When enabled, this plugin will inject and configure the VEAF Framework scripts in the mission. + +These scripts add a lot of runtime functionalities : + +- spawning of units and groups (and portable TACANs) +- air-to-ground missions +- air-to-air missions +- transport missions +- carrier operations (not Moose) +- tanker move +- weather and ATC +- shelling a zone, lighting it up +- managing assets (tankers, awacs, aircraft carriers) : getting info, state, respawning them if needed +- managing named points (position, info, ATC) +- managing a dynamic radio menu +- managing remote calls to the mission through NIOD (RPC) and SLMOD (LUA sockets) +- managing security (not allowing everyone to do every action) +- define groups templates + +For more information, please visit the [VEAF Framework documentation site](https://veaf.github.io/VEAF-Mission-Creation-Tools/) (work in progress) + +## Custom plugins + +Custom scripts can also be injected by dropping them in the `resources/plugins/custom` folder, and writing a `__plugins.lst` file listing them in order. +See the `__plugins.lst.sample` file for an example. + +## New settings pages + +![New settings pages](0.png "New settings pages") + +Custom plugins can be enabled or disabled in the new *LUA Plugins* settings page. + +![LUA Plugins settings page](1.png "LUA Plugins settings page") + +For plugins which expose specific options (such as "use smoke" for the *JTACAutoLase* plugin), the *LUA Plugins Options* settings page lists these options. + +![LUA Plugins Options settings page](2.png "LUA Plugins settings page") + + From 373924a9590c180e7956c4a5a12e812ed3020baa Mon Sep 17 00:00:00 2001 From: David Pierron Date: Thu, 15 Oct 2020 16:21:39 +0200 Subject: [PATCH 07/43] small changes to veaf plugin --- plugin/veaf_plugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugin/veaf_plugin.py b/plugin/veaf_plugin.py index c32cd86a..963ca4f8 100644 --- a/plugin/veaf_plugin.py +++ b/plugin/veaf_plugin.py @@ -86,5 +86,5 @@ class VeafPlugin(BasePlugin): def injectConfiguration(self, operation): if super().injectConfiguration(operation): - operation.injectPluginScript("veaf", "src\\config\\missionConfig.lua", "missionconfig") + operation.injectPluginScript("veaf", "src\\config\\missionConfig.lua", "veaf-config") From 8dc531bb7ff68461577a6e094b1017df1226eaee Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Thu, 15 Oct 2020 17:57:25 -0700 Subject: [PATCH 08/43] Make the departure time non-editable. Fixes part 2 of https://github.com/Khopa/dcs_liberation/issues/207. --- .../windows/mission/flight/QFlightPlanner.py | 6 +++- .../settings/QFlightDepartureDisplay.py | 32 ++++++++++++++++++ .../flight/settings/QFlightDepartureEditor.py | 31 ----------------- .../settings/QGeneralFlightSettingsTab.py | 33 ++++++++++--------- 4 files changed, 54 insertions(+), 48 deletions(-) create mode 100644 qt_ui/windows/mission/flight/settings/QFlightDepartureDisplay.py delete mode 100644 qt_ui/windows/mission/flight/settings/QFlightDepartureEditor.py diff --git a/qt_ui/windows/mission/flight/QFlightPlanner.py b/qt_ui/windows/mission/flight/QFlightPlanner.py index af48219c..b4eb9b36 100644 --- a/qt_ui/windows/mission/flight/QFlightPlanner.py +++ b/qt_ui/windows/mission/flight/QFlightPlanner.py @@ -19,11 +19,15 @@ class QFlightPlanner(QTabWidget): def __init__(self, package: Package, flight: Flight, game: Game): super().__init__() - self.general_settings_tab = QGeneralFlightSettingsTab(game, flight) + self.general_settings_tab = QGeneralFlightSettingsTab( + game, package, flight + ) + # noinspection PyUnresolvedReferences self.general_settings_tab.on_flight_settings_changed.connect( lambda: self.on_planned_flight_changed.emit()) self.payload_tab = QFlightPayloadTab(flight, game) self.waypoint_tab = QFlightWaypointTab(game, package, flight) + # noinspection PyUnresolvedReferences self.waypoint_tab.on_flight_changed.connect( lambda: self.on_planned_flight_changed.emit()) self.addTab(self.general_settings_tab, "General Flight settings") diff --git a/qt_ui/windows/mission/flight/settings/QFlightDepartureDisplay.py b/qt_ui/windows/mission/flight/settings/QFlightDepartureDisplay.py new file mode 100644 index 00000000..a720cc8b --- /dev/null +++ b/qt_ui/windows/mission/flight/settings/QFlightDepartureDisplay.py @@ -0,0 +1,32 @@ +import datetime + +from PySide2.QtWidgets import QLabel, QHBoxLayout, QGroupBox, QVBoxLayout + +from gen.ato import Package +from gen.flights.flight import Flight +from gen.flights.traveltime import TotEstimator + + +# TODO: Remove? +class QFlightDepartureDisplay(QGroupBox): + + def __init__(self, package: Package, flight: Flight): + super().__init__("Departure") + + layout = QVBoxLayout() + + departure_row = QHBoxLayout() + layout.addLayout(departure_row) + + estimator = TotEstimator(package) + delay = datetime.timedelta(seconds=estimator.mission_start_time(flight)) + + departure_row.addWidget(QLabel( + f"Departing from {flight.from_cp.name}" + )) + departure_row.addWidget(QLabel(f"At T+{delay}")) + + layout.addWidget(QLabel("Determined based on the package TOT. Edit the " + "package to adjust the TOT.")) + + self.setLayout(layout) diff --git a/qt_ui/windows/mission/flight/settings/QFlightDepartureEditor.py b/qt_ui/windows/mission/flight/settings/QFlightDepartureEditor.py deleted file mode 100644 index abf429cf..00000000 --- a/qt_ui/windows/mission/flight/settings/QFlightDepartureEditor.py +++ /dev/null @@ -1,31 +0,0 @@ -from PySide2.QtWidgets import QLabel, QHBoxLayout, QGroupBox, QSpinBox - - -# TODO: Remove? -class QFlightDepartureEditor(QGroupBox): - - def __init__(self, flight): - super(QFlightDepartureEditor, self).__init__("Departure") - self.flight = flight - - layout = QHBoxLayout() - self.depart_from = QLabel("Departing from " + self.flight.from_cp.name + "") - self.depart_at_t = QLabel("At T +") - self.minutes = QLabel(" minutes") - - self.departure_delta = QSpinBox(self) - self.departure_delta.setMinimum(0) - self.departure_delta.setMaximum(120) - self.departure_delta.setValue(self.flight.scheduled_in // 60) - self.departure_delta.valueChanged.connect(self.change_scheduled) - - layout.addWidget(self.depart_from) - layout.addWidget(self.depart_at_t) - layout.addWidget(self.departure_delta) - layout.addWidget(self.minutes) - self.setLayout(layout) - - self.changed = self.departure_delta.valueChanged - - def change_scheduled(self): - self.flight.scheduled_in = int(self.departure_delta.value() * 60) diff --git a/qt_ui/windows/mission/flight/settings/QGeneralFlightSettingsTab.py b/qt_ui/windows/mission/flight/settings/QGeneralFlightSettingsTab.py index 99f2b63f..f1419669 100644 --- a/qt_ui/windows/mission/flight/settings/QGeneralFlightSettingsTab.py +++ b/qt_ui/windows/mission/flight/settings/QGeneralFlightSettingsTab.py @@ -2,26 +2,29 @@ from PySide2.QtCore import Signal from PySide2.QtWidgets import QFrame, QGridLayout, QVBoxLayout from game import Game +from gen.ato import Package from gen.flights.flight import Flight -from qt_ui.windows.mission.flight.settings.QFlightDepartureEditor import QFlightDepartureEditor -from qt_ui.windows.mission.flight.settings.QFlightSlotEditor import QFlightSlotEditor -from qt_ui.windows.mission.flight.settings.QFlightStartType import QFlightStartType -from qt_ui.windows.mission.flight.settings.QFlightTypeTaskInfo import QFlightTypeTaskInfo +from qt_ui.windows.mission.flight.settings.QFlightDepartureDisplay import \ + QFlightDepartureDisplay +from qt_ui.windows.mission.flight.settings.QFlightSlotEditor import \ + QFlightSlotEditor +from qt_ui.windows.mission.flight.settings.QFlightStartType import \ + QFlightStartType +from qt_ui.windows.mission.flight.settings.QFlightTypeTaskInfo import \ + QFlightTypeTaskInfo class QGeneralFlightSettingsTab(QFrame): on_flight_settings_changed = Signal() - def __init__(self, game: Game, flight: Flight): - super(QGeneralFlightSettingsTab, self).__init__() - self.flight = flight - self.game = game + def __init__(self, game: Game, package: Package, flight: Flight): + super().__init__() layout = QGridLayout() - flight_info = QFlightTypeTaskInfo(self.flight) - flight_departure = QFlightDepartureEditor(self.flight) - flight_slots = QFlightSlotEditor(self.flight, self.game) - flight_start_type = QFlightStartType(self.flight) + flight_info = QFlightTypeTaskInfo(flight) + flight_departure = QFlightDepartureDisplay(package, flight) + flight_slots = QFlightSlotEditor(flight, game) + flight_start_type = QFlightStartType(flight) layout.addWidget(flight_info, 0, 0) layout.addWidget(flight_departure, 1, 0) layout.addWidget(flight_slots, 2, 0) @@ -31,8 +34,6 @@ class QGeneralFlightSettingsTab(QFrame): layout.addLayout(vstretch, 3, 0) self.setLayout(layout) - flight_start_type.setEnabled(self.flight.client_count > 0) + flight_start_type.setEnabled(flight.client_count > 0) flight_slots.changed.connect( - lambda: flight_start_type.setEnabled(self.flight.client_count > 0)) - flight_departure.changed.connect( - lambda: self.on_flight_settings_changed.emit()) + lambda: flight_start_type.setEnabled(flight.client_count > 0)) From de43a1215c0f73cdadfafde4fe1c004174ca1a54 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Thu, 15 Oct 2020 18:03:03 -0700 Subject: [PATCH 09/43] Update departure time when TOT is changed. Fixes https://github.com/Khopa/dcs_liberation/issues/207 --- qt_ui/models.py | 1 + qt_ui/windows/mission/QPackageDialog.py | 1 + 2 files changed, 2 insertions(+) diff --git a/qt_ui/models.py b/qt_ui/models.py index ba816fd1..27d75e7b 100644 --- a/qt_ui/models.py +++ b/qt_ui/models.py @@ -164,6 +164,7 @@ class PackageModel(QAbstractListModel): def update_tot(self, tot: int) -> None: self.package.time_over_target = tot + self.layoutChanged.emit() @property def mission_target(self) -> MissionTarget: diff --git a/qt_ui/windows/mission/QPackageDialog.py b/qt_ui/windows/mission/QPackageDialog.py index 3c64c160..696a8ce3 100644 --- a/qt_ui/windows/mission/QPackageDialog.py +++ b/qt_ui/windows/mission/QPackageDialog.py @@ -89,6 +89,7 @@ class QPackageDialog(QDialog): self.tot_spinner = QTimeEdit(time) self.tot_spinner.setMinimumTime(QTime(0, 0)) self.tot_spinner.setDisplayFormat("T+hh:mm:ss") + self.tot_spinner.timeChanged.connect(self.save_tot) self.tot_column.addWidget(self.tot_spinner) self.package_view = QFlightList(self.package_model) From f7fec834e62d987ce666e31d0eaad085821d58d4 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Thu, 15 Oct 2020 20:23:57 -0700 Subject: [PATCH 10/43] Add a button to automatically set the package TOT. --- qt_ui/windows/mission/QPackageDialog.py | 32 +++++++++++++++++-------- 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/qt_ui/windows/mission/QPackageDialog.py b/qt_ui/windows/mission/QPackageDialog.py index 696a8ce3..8882e0bf 100644 --- a/qt_ui/windows/mission/QPackageDialog.py +++ b/qt_ui/windows/mission/QPackageDialog.py @@ -16,6 +16,7 @@ from game.game import Game from gen.ato import Package from gen.flights.flight import Flight from gen.flights.flightplan import FlightPlanBuilder +from gen.flights.traveltime import TotEstimator from qt_ui.models import AtoModel, PackageModel from qt_ui.uiconstants import EVENT_ICONS from qt_ui.widgets.ato import QFlightList @@ -77,21 +78,20 @@ class QPackageDialog(QDialog): self.tot_label = QLabel("Time Over Target:") self.tot_column.addWidget(self.tot_label) - if self.package_model.package.time_over_target is None: - time = None - else: - delay = self.package_model.package.time_over_target - hours = delay // 3600 - minutes = delay // 60 % 60 - seconds = delay % 60 - time = QTime(hours, minutes, seconds) - - self.tot_spinner = QTimeEdit(time) + self.tot_spinner = QTimeEdit(self.tot_qtime()) self.tot_spinner.setMinimumTime(QTime(0, 0)) self.tot_spinner.setDisplayFormat("T+hh:mm:ss") self.tot_spinner.timeChanged.connect(self.save_tot) self.tot_column.addWidget(self.tot_spinner) + self.reset_tot_button = QPushButton("Reset TOT") + self.reset_tot_button.setToolTip( + "Sets the package TOT to the earliest time that all flights can " + "arrive at the target." + ) + self.reset_tot_button.clicked.connect(self.reset_tot) + self.tot_column.addWidget(self.reset_tot_button) + self.package_view = QFlightList(self.package_model) self.package_view.selectionModel().selectionChanged.connect( self.on_selection_changed @@ -117,6 +117,13 @@ class QPackageDialog(QDialog): self.finished.connect(self.on_close) + def tot_qtime(self) -> QTime: + delay = self.package_model.package.time_over_target + hours = delay // 3600 + minutes = delay // 60 % 60 + seconds = delay % 60 + return QTime(hours, minutes, seconds) + @staticmethod def on_close(_result) -> None: GameUpdateSignal.get_instance().redraw_flight_paths() @@ -126,6 +133,11 @@ class QPackageDialog(QDialog): seconds = time.hour() * 3600 + time.minute() * 60 + time.second() self.package_model.update_tot(seconds) + def reset_tot(self) -> None: + self.package_model.update_tot( + TotEstimator(self.package_model.package).earliest_tot()) + self.tot_spinner.setTime(self.tot_qtime()) + def on_selection_changed(self, selected: QItemSelection, _deselected: QItemSelection) -> None: """Updates the state of the delete button.""" From 8b87c4386922d2ff92cf30f764dc0218e82443db Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Thu, 15 Oct 2020 21:04:22 -0700 Subject: [PATCH 11/43] Warn the player about misconfigured TOTs. --- qt_ui/widgets/QTopPanel.py | 62 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 60 insertions(+), 2 deletions(-) diff --git a/qt_ui/widgets/QTopPanel.py b/qt_ui/widgets/QTopPanel.py index 99f0ac9f..fa76910f 100644 --- a/qt_ui/widgets/QTopPanel.py +++ b/qt_ui/widgets/QTopPanel.py @@ -1,4 +1,4 @@ -from typing import Optional +from typing import List, Optional from PySide2.QtWidgets import ( QFrame, @@ -11,6 +11,8 @@ from PySide2.QtWidgets import ( import qt_ui.uiconstants as CONST from game import Game from game.event import CAP, CAS, FrontlineAttackEvent +from gen.ato import Package +from gen.flights.traveltime import TotEstimator from qt_ui.models import GameModel from qt_ui.widgets.QBudgetBox import QBudgetBox from qt_ui.widgets.QFactionsInfos import QFactionsInfos @@ -117,6 +119,22 @@ class QTopPanel(QFrame): GameUpdateSignal.get_instance().updateGame(self.game) self.proceedButton.setEnabled(True) + def negative_start_packages(self) -> List[Package]: + packages = [] + for package in self.game_model.ato_model.ato.packages: + estimator = TotEstimator(package) + for flight in package.flights: + if estimator.mission_start_time(flight) < 0: + packages.append(package) + break + return packages + + @staticmethod + def fix_tots(packages: List[Package]) -> None: + for package in packages: + estimator = TotEstimator(package) + package.time_over_target = estimator.earliest_tot() + def ato_has_clients(self) -> bool: for package in self.game.blue_ato.packages: for flight in package.flights: @@ -142,12 +160,52 @@ class QTopPanel(QFrame): ) return result == QMessageBox.Yes + def confirm_negative_start_time(self, + negative_starts: List[Package]) -> bool: + formatted = '
'.join( + [f"{p.primary_task.name} {p.target.name}" for p in negative_starts] + ) + mbox = QMessageBox( + QMessageBox.Question, + "Continue with past start times?", + ("Some flights in the following packages have start times set " + "earlier than mission start time:
" + "
" + f"{formatted}
" + "
" + "Flight start times are estimated based on the package TOT, so it " + "is possible that not all flights will be able to reach the " + "target area at their assigned times.
" + "
" + "You can either continue with the mission as planned, with the " + "misplanned flights potentially flying too fast and/or missing " + "their rendezvous; automatically fix negative TOTs; or cancel " + "mission start and fix the packages manually."), + parent=self + ) + auto = mbox.addButton("Fix TOTs automatically", QMessageBox.ActionRole) + ignore = mbox.addButton("Continue without fixing", + QMessageBox.DestructiveRole) + cancel = mbox.addButton(QMessageBox.Cancel) + mbox.setEscapeButton(cancel) + mbox.exec_() + clicked = mbox.clickedButton() + if clicked == auto: + self.fix_tots(negative_starts) + return True + elif clicked == ignore: + return True + return False + def launch_mission(self): """Finishes planning and waits for mission completion.""" if not self.ato_has_clients() and not self.confirm_no_client_launch(): return - # TODO: Verify no negative start times. + negative_starts = self.negative_start_packages() + if negative_starts: + if not self.confirm_negative_start_time(negative_starts): + return # TODO: Refactor this nonsense. game_event = None From 9efecf951470ead4f8122fa09b0dd04eea54d4fb Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Thu, 15 Oct 2020 21:33:04 -0700 Subject: [PATCH 12/43] Fix inventory handling for new packages. --- qt_ui/windows/mission/QPackageDialog.py | 53 ++++++++----------------- 1 file changed, 17 insertions(+), 36 deletions(-) diff --git a/qt_ui/windows/mission/QPackageDialog.py b/qt_ui/windows/mission/QPackageDialog.py index 8882e0bf..c19b9af2 100644 --- a/qt_ui/windows/mission/QPackageDialog.py +++ b/qt_ui/windows/mission/QPackageDialog.py @@ -35,12 +35,6 @@ class QPackageDialog(QDialog): #: Emitted when a change is made to the package. package_changed = Signal() - #: Emitted when a flight is added to the package. - flight_added = Signal(Flight) - - #: Emitted when a flight is removed from the package. - flight_removed = Signal(Flight) - def __init__(self, game: Game, model: PackageModel) -> None: super().__init__() self.game = game @@ -115,7 +109,9 @@ class QPackageDialog(QDialog): self.setLayout(self.layout) + self.accepted.connect(self.on_save) self.finished.connect(self.on_close) + self.rejected.connect(self.on_cancel) def tot_qtime(self) -> QTime: delay = self.package_model.package.time_over_target @@ -124,10 +120,16 @@ class QPackageDialog(QDialog): seconds = delay % 60 return QTime(hours, minutes, seconds) + def on_cancel(self) -> None: + pass + @staticmethod def on_close(_result) -> None: GameUpdateSignal.get_instance().redraw_flight_paths() + def on_save(self) -> None: + self.save_tot() + def save_tot(self) -> None: time = self.tot_spinner.time() seconds = time.hour() * 3600 + time.minute() * 60 + time.second() @@ -152,14 +154,13 @@ class QPackageDialog(QDialog): def add_flight(self, flight: Flight) -> None: """Adds the new flight to the package.""" + self.game.aircraft_inventory.claim_for_flight(flight) self.package_model.add_flight(flight) planner = FlightPlanBuilder(self.game, self.package_model.package, is_player=True) planner.populate_flight_plan(flight) # noinspection PyUnresolvedReferences self.package_changed.emit() - # noinspection PyUnresolvedReferences - self.flight_added.emit(flight) def on_delete_flight(self) -> None: """Removes the selected flight from the package.""" @@ -167,11 +168,10 @@ class QPackageDialog(QDialog): if flight is None: logging.error(f"Cannot delete flight when no flight is selected.") return + self.game.aircraft_inventory.return_from_flight(flight) self.package_model.delete_flight(flight) # noinspection PyUnresolvedReferences self.package_changed.emit() - # noinspection PyUnresolvedReferences - self.flight_removed.emit(flight) class QNewPackageDialog(QPackageDialog): @@ -187,7 +187,7 @@ class QNewPackageDialog(QPackageDialog): self.save_button = QPushButton("Save") self.save_button.setProperty("style", "start-button") - self.save_button.clicked.connect(self.on_save) + self.save_button.clicked.connect(self.accept) self.button_layout.addWidget(self.save_button) self.delete_flight_button.clicked.connect(self.on_delete_flight) @@ -198,11 +198,13 @@ class QNewPackageDialog(QPackageDialog): Empty packages may be created. They can be modified later, and will have no effect if empty when the mission is generated. """ - self.save_tot() + super().on_save() self.ato_model.add_package(self.package_model.package) + + def on_cancel(self) -> None: + super().on_cancel() for flight in self.package_model.package.flights: - self.game.aircraft_inventory.claim_for_flight(flight) - self.close() + self.game_model.game.aircraft_inventory.return_from_flight(flight) class QEditPackageDialog(QPackageDialog): @@ -223,30 +225,9 @@ class QEditPackageDialog(QPackageDialog): self.done_button = QPushButton("Done") self.done_button.setProperty("style", "start-button") - self.done_button.clicked.connect(self.on_done) + self.done_button.clicked.connect(self.accept) self.button_layout.addWidget(self.done_button) - # noinspection PyUnresolvedReferences - self.flight_added.connect(self.on_flight_added) - # noinspection PyUnresolvedReferences - self.flight_removed.connect(self.on_flight_removed) - - # TODO: Make the new package dialog do this too, return on cancel. - # Not claiming the aircraft when they are added to the planner means that - # inventory counts are not updated until after the new package is updated, - # so you can add an infinite number of aircraft to a new package in the UI, - # which will crash when the flight package is saved. - def on_flight_added(self, flight: Flight) -> None: - self.game.aircraft_inventory.claim_for_flight(flight) - - def on_flight_removed(self, flight: Flight) -> None: - self.game.aircraft_inventory.return_from_flight(flight) - - def on_done(self) -> None: - """Closes the window.""" - self.save_tot() - self.close() - def on_delete(self) -> None: """Removes the viewed package from the ATO.""" # The ATO model returns inventory for us when deleting a package. From 5a027c552e835d63f31e6f67363453198e04ccad Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Thu, 15 Oct 2020 21:39:52 -0700 Subject: [PATCH 13/43] Point players to the kneeboard from the briefing. --- gen/briefinggen.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/gen/briefinggen.py b/gen/briefinggen.py index d52f25cc..1eef67a7 100644 --- a/gen/briefinggen.py +++ b/gen/briefinggen.py @@ -129,6 +129,11 @@ class BriefingGenerator(MissionInfoGenerator): self.description += "DCS Liberation turn #" + str(self.game.turn) + "\n" self.description += "=" * 15 + "\n\n" + self.description += ( + "Most briefing information, including communications and flight " + "plan information, can be found on your kneeboard.\n\n" + ) + self.generate_ongoing_war_text() self.description += "\n"*2 From 2fa3b26119b1a07cc1345eaf6ac5503b8dd7ead0 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Thu, 15 Oct 2020 23:48:42 -0700 Subject: [PATCH 14/43] Improve speed estimations. Reasonable ground speed depends a lot on altitude, so plumb that information through to the speed estimator. Also adds calculations for ground speed based on desired mach. I don't know if DCS is using the same formulas, but we should at least be pretty close. --- gen/aircraft.py | 4 +- gen/flights/flight.py | 10 +- gen/flights/traveltime.py | 127 +++++++++++++++++------- qt_ui/widgets/QTopPanel.py | 2 + qt_ui/windows/mission/QPackageDialog.py | 7 +- 5 files changed, 111 insertions(+), 39 deletions(-) diff --git a/gen/aircraft.py b/gen/aircraft.py index 49c6e8cd..13010bb3 100644 --- a/gen/aircraft.py +++ b/gen/aircraft.py @@ -788,6 +788,8 @@ class AircraftConflictGenerator: self.clear_parking_slots() for package in ato.packages: + if not package.flights: + continue timing = PackageWaypointTiming.for_package(package) for flight in package.flights: culled = self.game.position_culled(flight.from_cp.position) @@ -1130,7 +1132,7 @@ class HoldPointBuilder(PydcsWaypointBuilder): pattern=OrbitAction.OrbitPattern.Circle )) loiter.stop_after_time( - self.timing.push_time(self.flight, waypoint.position)) + self.timing.push_time(self.flight, self.waypoint)) waypoint.add_task(loiter) return waypoint diff --git a/gen/flights/flight.py b/gen/flights/flight.py index c9031bb4..85fe4c17 100644 --- a/gen/flights/flight.py +++ b/gen/flights/flight.py @@ -1,5 +1,5 @@ from enum import Enum -from typing import Dict, Optional +from typing import Dict, Iterable, Optional from game import db from dcs.unittype import UnitType @@ -151,6 +151,14 @@ class Flight: return self.flight_type.name + " | " + str(self.count) + "x" + db.unit_type_name(self.unit_type) \ + " (" + str(len(self.points)) + " wpt)" + def waypoint_with_type( + self, + types: Iterable[FlightWaypointType]) -> Optional[FlightWaypoint]: + for waypoint in self.points: + if waypoint.waypoint_type in types: + return waypoint + return None + # Test if __name__ == '__main__': diff --git a/gen/flights/traveltime.py b/gen/flights/traveltime.py index 87d2817d..4679a466 100644 --- a/gen/flights/traveltime.py +++ b/gen/flights/traveltime.py @@ -1,6 +1,7 @@ from __future__ import annotations import logging +import math from dataclasses import dataclass from typing import Iterable, Optional @@ -19,23 +20,73 @@ from gen.flights.flight import ( CAP_DURATION = 30 # Minutes CAP_TYPES = (FlightType.BARCAP, FlightType.CAP) +INGRESS_TYPES = { + FlightWaypointType.INGRESS_CAS, + FlightWaypointType.INGRESS_SEAD, + FlightWaypointType.INGRESS_STRIKE, +} + +IP_TYPES = { + FlightWaypointType.INGRESS_CAS, + FlightWaypointType.INGRESS_SEAD, + FlightWaypointType.INGRESS_STRIKE, + FlightWaypointType.PATROL_TRACK, +} + class GroundSpeed: - @classmethod - def for_package(cls, package: Package) -> int: - speeds = [] - for flight in package.flights: - speeds.append(cls.for_flight(flight)) - return min(speeds) # knots - @staticmethod - def for_flight(_flight: Flight) -> int: + def mission_speed(package: Package) -> int: + speeds = set() + for flight in package.flights: + waypoint = flight.waypoint_with_type(IP_TYPES) + if waypoint is None: + logging.error(f"Could not find ingress point for {flight}") + continue + speeds.add(GroundSpeed.for_flight(flight, waypoint.alt)) + return min(speeds) + + @classmethod + def for_flight(cls, _flight: Flight, altitude: int) -> int: # TODO: Gather data so this is useful. # TODO: Expose both a cruise speed and target speed. # The cruise speed can be used for ascent, hold, join, and RTB to save # on fuel, but mission speed will be fast enough to keep the flight # safer. - return 400 # knots + return int(cls.from_mach(0.8, altitude)) # knots + + @staticmethod + def from_mach(mach: float, altitude: int) -> float: + """Returns the ground speed in knots for the given mach and altitude. + + Args: + mach: The mach number to convert to ground speed. + altitude: The altitude in feet. + + Returns: + The ground speed corresponding to the given altitude and mach number + in knots. + """ + # https://www.grc.nasa.gov/WWW/K-12/airplane/atmos.html + if altitude <= 36152: + temperature_f = 59 - 0.00356 * altitude + else: + # There's another formula for altitudes over 82k feet, but we better + # not be planning waypoints that high... + temperature_f = -70 + + temperature_k = (temperature_f + 459.67) * (5 / 9) + + # https://www.engineeringtoolbox.com/specific-heat-ratio-d_602.html + # Dependent on temperature, but varies very little (+/-0.001) + # between -40F and 180F. + heat_capacity_ratio = 1.4 + + # https://www.grc.nasa.gov/WWW/K-12/airplane/sound.html + gas_constant = 286 # m^2/s^2/K + c_sound = math.sqrt(heat_capacity_ratio * gas_constant * temperature_k) + # c_sound is in m/s, convert to knots. + return (c_sound * 1.944) * mach class TravelTime: @@ -97,13 +148,7 @@ class TotEstimator: The earliest possible TOT for the given flight in seconds. Returns 0 if an ingress point cannot be found. """ - stop_types = { - FlightWaypointType.PATROL_TRACK, - FlightWaypointType.INGRESS_CAS, - FlightWaypointType.INGRESS_SEAD, - FlightWaypointType.INGRESS_STRIKE, - } - time_to_ingress = self.estimate_waypoints_to_target(flight, stop_types) + time_to_ingress = self.estimate_waypoints_to_target(flight, IP_TYPES) if time_to_ingress is None: logging.warning( f"Found no ingress types. Cannot estimate TOT for {flight}") @@ -119,7 +164,7 @@ class TotEstimator: assert self.package.waypoints is not None time_to_target = TravelTime.between_points( self.package.waypoints.ingress, self.package.target.position, - GroundSpeed.for_package(self.package)) + GroundSpeed.mission_speed(self.package)) return sum([ self.estimate_startup(flight), self.estimate_ground_ops(flight), @@ -146,30 +191,38 @@ class TotEstimator: self, flight: Flight, stop_types: Iterable[FlightWaypointType]) -> Optional[int]: total = 0 + # TODO: This is AGL. We want MSL. + previous_altitude = 0 previous_position = flight.from_cp.position for waypoint in flight.points: position = Point(waypoint.x, waypoint.y) total += TravelTime.between_points( previous_position, position, - self.speed_to_waypoint(flight, waypoint) + self.speed_to_waypoint(flight, waypoint, previous_altitude) ) previous_position = position + previous_altitude = waypoint.alt if waypoint.waypoint_type in stop_types: return total return None - def speed_to_waypoint(self, flight: Flight, - waypoint: FlightWaypoint) -> int: + def speed_to_waypoint(self, flight: Flight, waypoint: FlightWaypoint, + from_altitude: int) -> int: + # TODO: Adjust if AGL. + # We don't have an exact heightmap, but we should probably be performing + # *some* adjustment for NTTR since the minimum altitude of the map is + # near 2000 ft MSL. + alt_for_speed = min(from_altitude, waypoint.alt) pre_join = (FlightWaypointType.LOITER, FlightWaypointType.JOIN) if waypoint.waypoint_type == FlightWaypointType.ASCEND_POINT: # Flights that start airborne already have some altitude and a good # amount of speed. factor = 1.0 if flight.start_type == "In Flight" else 0.5 - return int(GroundSpeed.for_flight(flight) * factor) + return int(GroundSpeed.for_flight(flight, alt_for_speed) * factor) elif waypoint.waypoint_type in pre_join: - return GroundSpeed.for_flight(flight) - return GroundSpeed.for_package(self.package) + return GroundSpeed.for_flight(flight, alt_for_speed) + return GroundSpeed.mission_speed(self.package) @dataclass(frozen=True) @@ -209,12 +262,12 @@ class PackageWaypointTiming: else: return self.egress - def push_time(self, flight: Flight, hold_point: Point) -> int: + def push_time(self, flight: Flight, hold_point: FlightWaypoint) -> int: assert self.package.waypoints is not None return self.join - TravelTime.between_points( - hold_point, + Point(hold_point.x, hold_point.y), self.package.waypoints.join, - GroundSpeed.for_flight(flight) + GroundSpeed.for_flight(flight, hold_point.alt) ) def tot_for_waypoint(self, waypoint: FlightWaypoint) -> Optional[int]: @@ -224,15 +277,9 @@ class PackageWaypointTiming: FlightWaypointType.TARGET_SHIP, ) - ingress_types = ( - FlightWaypointType.INGRESS_CAS, - FlightWaypointType.INGRESS_SEAD, - FlightWaypointType.INGRESS_STRIKE, - ) - if waypoint.waypoint_type == FlightWaypointType.JOIN: return self.join - elif waypoint.waypoint_type in ingress_types: + elif waypoint.waypoint_type in INGRESS_TYPES: return self.ingress elif waypoint.waypoint_type in target_types: return self.target @@ -247,7 +294,7 @@ class PackageWaypointTiming: def depart_time_for_waypoint(self, waypoint: FlightWaypoint, flight: Flight) -> Optional[int]: if waypoint.waypoint_type == FlightWaypointType.LOITER: - return self.push_time(flight, Point(waypoint.x, waypoint.y)) + return self.push_time(flight, waypoint) elif waypoint.waypoint_type == FlightWaypointType.PATROL: return self.race_track_end return None @@ -256,7 +303,17 @@ class PackageWaypointTiming: def for_package(cls, package: Package) -> PackageWaypointTiming: assert package.waypoints is not None - group_ground_speed = GroundSpeed.for_package(package) + # TODO: Plan similar altitudes for the in-country leg of the mission. + # Waypoint altitudes for a given flight *shouldn't* differ too much + # between the join and split points, so we don't need speeds for each + # leg individually since they should all be fairly similar. This doesn't + # hold too well right now since nothing is stopping each waypoint from + # jumping 20k feet each time, but that's a huge waste of energy we + # should be avoiding anyway. + if not package.flights: + raise ValueError("Cannot plan TOT for package with no flights") + + group_ground_speed = GroundSpeed.mission_speed(package) ingress = package.time_over_target - TravelTime.between_points( package.waypoints.ingress, diff --git a/qt_ui/widgets/QTopPanel.py b/qt_ui/widgets/QTopPanel.py index fa76910f..d115c98b 100644 --- a/qt_ui/widgets/QTopPanel.py +++ b/qt_ui/widgets/QTopPanel.py @@ -122,6 +122,8 @@ class QTopPanel(QFrame): def negative_start_packages(self) -> List[Package]: packages = [] for package in self.game_model.ato_model.ato.packages: + if not package.flights: + continue estimator = TotEstimator(package) for flight in package.flights: if estimator.mission_start_time(flight) < 0: diff --git a/qt_ui/windows/mission/QPackageDialog.py b/qt_ui/windows/mission/QPackageDialog.py index c19b9af2..eecc9fa8 100644 --- a/qt_ui/windows/mission/QPackageDialog.py +++ b/qt_ui/windows/mission/QPackageDialog.py @@ -136,8 +136,11 @@ class QPackageDialog(QDialog): self.package_model.update_tot(seconds) def reset_tot(self) -> None: - self.package_model.update_tot( - TotEstimator(self.package_model.package).earliest_tot()) + if not list(self.package_model.flights): + self.package_model.update_tot(0) + else: + self.package_model.update_tot( + TotEstimator(self.package_model.package).earliest_tot()) self.tot_spinner.setTime(self.tot_qtime()) def on_selection_changed(self, selected: QItemSelection, From 69bf3999aa9c0f81aa479656e6d6ac25047c99ec Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Fri, 16 Oct 2020 03:08:21 -0700 Subject: [PATCH 15/43] Fix new package double flight delete. This button was connected twice. --- qt_ui/windows/mission/QPackageDialog.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/qt_ui/windows/mission/QPackageDialog.py b/qt_ui/windows/mission/QPackageDialog.py index eecc9fa8..2ab035c3 100644 --- a/qt_ui/windows/mission/QPackageDialog.py +++ b/qt_ui/windows/mission/QPackageDialog.py @@ -193,8 +193,6 @@ class QNewPackageDialog(QPackageDialog): self.save_button.clicked.connect(self.accept) self.button_layout.addWidget(self.save_button) - self.delete_flight_button.clicked.connect(self.on_delete_flight) - def on_save(self) -> None: """Saves the created package. From 2814876976dac1589b98e1b894a4026ce5941617 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Fri, 16 Oct 2020 03:09:21 -0700 Subject: [PATCH 16/43] Fix A-10C II radio data. These are two different planes. --- gen/aircraft.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/gen/aircraft.py b/gen/aircraft.py index 13010bb3..070670f6 100644 --- a/gen/aircraft.py +++ b/gen/aircraft.py @@ -415,6 +415,7 @@ class SCR522RadioChannelAllocator(RadioChannelAllocator): # TODO : Some GCI on Channel 4 ? + @dataclass(frozen=True) class AircraftData: """Additional aircraft data not exposed by pydcs.""" @@ -438,7 +439,9 @@ class AircraftData: AIRCRAFT_DATA: Dict[str, AircraftData] = { "A-10C": AircraftData( inter_flight_radio=get_radio("AN/ARC-164"), - intra_flight_radio=get_radio("AN/ARC-164"), # VHF for intraflight is not accepted anymore by DCS (see https://forums.eagle.ru/showthread.php?p=4499738) + # VHF for intraflight is not accepted anymore by DCS + # (see https://forums.eagle.ru/showthread.php?p=4499738). + intra_flight_radio=get_radio("AN/ARC-164"), channel_allocator=WarthogRadioChannelAllocator() ), @@ -528,6 +531,7 @@ AIRCRAFT_DATA: Dict[str, AircraftData] = { channel_namer=SCR522ChannelNamer ), } +AIRCRAFT_DATA["A-10C_2"] = AIRCRAFT_DATA["A-10C"] AIRCRAFT_DATA["P-51D-30-NA"] = AIRCRAFT_DATA["P-51D"] AIRCRAFT_DATA["P-47D-30"] = AIRCRAFT_DATA["P-51D"] From 613f84aa3c949dc8e5a69a2f925315db553c42c9 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Fri, 16 Oct 2020 02:34:25 -0700 Subject: [PATCH 17/43] Add aircraft-specific speed estimates. --- gen/flights/traveltime.py | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/gen/flights/traveltime.py b/gen/flights/traveltime.py index 4679a466..17879616 100644 --- a/gen/flights/traveltime.py +++ b/gen/flights/traveltime.py @@ -6,6 +6,7 @@ from dataclasses import dataclass from typing import Iterable, Optional from dcs.mapping import Point +from dcs.unittype import FlyingType from game.utils import meter_to_nm from gen.ato import Package @@ -47,13 +48,31 @@ class GroundSpeed: return min(speeds) @classmethod - def for_flight(cls, _flight: Flight, altitude: int) -> int: - # TODO: Gather data so this is useful. + def for_flight(cls, flight: Flight, altitude: int) -> int: + if not issubclass(flight.unit_type, FlyingType): + raise TypeError("Flight has non-flying unit") + # TODO: Expose both a cruise speed and target speed. # The cruise speed can be used for ascent, hold, join, and RTB to save # on fuel, but mission speed will be fast enough to keep the flight # safer. - return int(cls.from_mach(0.8, altitude)) # knots + + c_sound_sea_level = 661.5 + + # DCS's max speed is in kph at 0 MSL. Convert to knots. + max_speed = flight.unit_type.max_speed * 0.539957 + if max_speed > c_sound_sea_level: + # Aircraft is supersonic. Limit to mach 0.8 to conserve fuel and + # account for heavily loaded jets. + return int(cls.from_mach(0.8, altitude)) + + # For subsonic aircraft, assume the aircraft can reasonably perform at + # 80% of its maximum, and that it can maintain the same mach at altitude + # as it can at sea level. This probably isn't great assumption, but + # might. be sufficient given the wiggle room. We can come up with + # another heuristic if needed. + mach = max_speed * 0.8 / c_sound_sea_level + return int(cls.from_mach(mach, altitude)) # knots @staticmethod def from_mach(mach: float, altitude: int) -> float: From e4852c74ab01f29360a5f6993aa0849151fc4854 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Fri, 16 Oct 2020 13:00:59 -0700 Subject: [PATCH 18/43] Remove completed TODOs/dead code. --- game/inventory.py | 10 ---------- gen/flights/flightplan.py | 1 - gen/flights/waypointbuilder.py | 6 ------ 3 files changed, 17 deletions(-) diff --git a/game/inventory.py b/game/inventory.py index 5ef68b04..ae75c837 100644 --- a/game/inventory.py +++ b/game/inventory.py @@ -65,16 +65,6 @@ class ControlPointAircraftInventory: if count > 0: yield aircraft, count - @property - def total_available(self) -> int: - """Returns the total number of aircraft available.""" - # TODO: Remove? - # This probably isn't actually useful. It's used by the AI flight - # planner to determine how many flights of a given type it should - # allocate, but it should probably be making that decision based on the - # number of aircraft available to perform a particular role. - return sum(self.inventory.values()) - def clear(self) -> None: """Clears all aircraft from the inventory.""" self.inventory.clear() diff --git a/gen/flights/flightplan.py b/gen/flights/flightplan.py index 74462c2d..8619fa3f 100644 --- a/gen/flights/flightplan.py +++ b/gen/flights/flightplan.py @@ -424,7 +424,6 @@ class FlightPlanBuilder: def _heading_to_package_airfield(self, point: Point) -> int: return self.package_airfield().position.heading_between_point(point) - # TODO: Set ingress/egress/join/split points in the Package. def package_airfield(self) -> ControlPoint: # We'll always have a package, but if this is being planned via the UI # it could be the first flight in the package. diff --git a/gen/flights/waypointbuilder.py b/gen/flights/waypointbuilder.py index a0374d25..fd4b5aed 100644 --- a/gen/flights/waypointbuilder.py +++ b/gen/flights/waypointbuilder.py @@ -267,12 +267,6 @@ class WaypointBuilder: waypoint.pretty_name = "Race-track start" self.waypoints.append(waypoint) - # TODO: Does this actually do anything? - # orbit0.targets.append(location) - # Note: Targets of PATROL TRACK waypoints are the points to be defended. - # orbit0.targets.append(flight.from_cp) - # orbit0.targets.append(center) - def race_track_end(self, position: Point, altitude: int) -> None: """Creates a racetrack end waypoint. From 9db41270f3442c8c7f01f5047eeca5c99271130d Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Fri, 16 Oct 2020 13:39:59 -0700 Subject: [PATCH 19/43] Add red ATO cheat option, show red flight plans. --- game/settings.py | 11 ++++++ qt_ui/models.py | 12 ++++-- qt_ui/widgets/map/QLiberationMap.py | 2 + qt_ui/windows/settings/QSettingsWindow.py | 47 ++++++++++++++++++++--- 4 files changed, 62 insertions(+), 10 deletions(-) diff --git a/game/settings.py b/game/settings.py index 4566ad0f..0f93c729 100644 --- a/game/settings.py +++ b/game/settings.py @@ -40,4 +40,15 @@ class Settings: self.perf_culling = False self.perf_culling_distance = 100 + # Cheating + self.show_red_ato = False + def __setstate__(self, state) -> None: + # __setstate__ is called with the dict of the object being unpickled. We + # can provide save compatibility for new settings options (which + # normally would not be present in the unpickled object) by creating a + # new settings object, updating it with the unpickled state, and + # updating our dict with that. + new_state = Settings().__dict__ + new_state.update(state) + self.__dict__.update(new_state) diff --git a/qt_ui/models.py b/qt_ui/models.py index 27d75e7b..6480d933 100644 --- a/qt_ui/models.py +++ b/qt_ui/models.py @@ -235,7 +235,7 @@ class AtoModel(QAbstractListModel): """Returns the package at the given index.""" return self.ato.packages[index.row()] - def replace_from_game(self, game: Optional[Game]) -> None: + def replace_from_game(self, game: Optional[Game], player: bool) -> None: """Updates the ATO object to match the updated game object. If the game is None (as is the case when no game has been loaded), an @@ -245,7 +245,10 @@ class AtoModel(QAbstractListModel): self.game = game self.package_models.clear() if self.game is not None: - self.ato = game.blue_ato + if player: + self.ato = game.blue_ato + else: + self.ato = game.red_ato else: self.ato = AirTaskingOrder() self.endResetModel() @@ -269,8 +272,8 @@ class GameModel: """ def __init__(self) -> None: self.game: Optional[Game] = None - # TODO: Add red ATO model, add cheat option to show red flight plan. self.ato_model = AtoModel(self.game, AirTaskingOrder()) + self.red_ato_model = AtoModel(self.game, AirTaskingOrder()) def set(self, game: Optional[Game]) -> None: """Updates the managed Game object. @@ -281,4 +284,5 @@ class GameModel: loaded. """ self.game = game - self.ato_model.replace_from_game(self.game) + self.ato_model.replace_from_game(self.game, player=True) + self.red_ato_model.replace_from_game(self.game, player=False) diff --git a/qt_ui/widgets/map/QLiberationMap.py b/qt_ui/widgets/map/QLiberationMap.py index 20f9e151..dde0893e 100644 --- a/qt_ui/widgets/map/QLiberationMap.py +++ b/qt_ui/widgets/map/QLiberationMap.py @@ -255,6 +255,8 @@ class QLiberationMap(QGraphicsView): if DisplayOptions.flight_paths.hide: return packages = list(self.game_model.ato_model.packages) + if self.game.settings.show_red_ato: + packages.extend(self.game_model.red_ato_model.packages) for p_idx, package_model in enumerate(packages): for f_idx, flight in enumerate(package_model.flights): selected = (p_idx, f_idx) == self.selected_flight diff --git a/qt_ui/windows/settings/QSettingsWindow.py b/qt_ui/windows/settings/QSettingsWindow.py index 6ccdb226..aafa52a9 100644 --- a/qt_ui/windows/settings/QSettingsWindow.py +++ b/qt_ui/windows/settings/QSettingsWindow.py @@ -1,18 +1,50 @@ import logging +from typing import Callable from PySide2.QtCore import QSize, Qt, QItemSelectionModel, QPoint from PySide2.QtGui import QStandardItemModel, QStandardItem -from PySide2.QtWidgets import QLabel, QDialog, QGridLayout, QListView, QStackedLayout, QComboBox, QWidget, \ - QAbstractItemView, QPushButton, QGroupBox, QCheckBox, QVBoxLayout, QSpinBox +from PySide2.QtWidgets import ( + QLabel, + QDialog, + QGridLayout, + QListView, + QStackedLayout, + QComboBox, + QWidget, + QAbstractItemView, + QPushButton, + QGroupBox, + QCheckBox, + QVBoxLayout, + QSpinBox, +) from dcs.forcedoptions import ForcedOptions import qt_ui.uiconstants as CONST from game.game import Game from game.infos.information import Information +from qt_ui.widgets.QLabeledWidget import QLabeledWidget from qt_ui.windows.GameUpdateSignal import GameUpdateSignal from qt_ui.windows.finances.QFinancesMenu import QHorizontalSeparationLine +class CheatSettingsBox(QGroupBox): + def __init__(self, game: Game, apply_settings: Callable[[], None]) -> None: + super().__init__("Cheat Settings") + self.main_layout = QVBoxLayout() + self.setLayout(self.main_layout) + + self.red_ato_checkbox = QCheckBox() + self.red_ato_checkbox.setChecked(game.settings.show_red_ato) + self.red_ato_checkbox.toggled.connect(apply_settings) + self.red_ato = QLabeledWidget("Show Red ATO:", self.red_ato_checkbox) + self.main_layout.addLayout(self.red_ato) + + @property + def show_red_ato(self) -> bool: + return self.red_ato_checkbox.isChecked() + + class QSettingsWindow(QDialog): def __init__(self, game: Game): @@ -263,9 +295,12 @@ class QSettingsWindow(QDialog): def initCheatLayout(self): self.cheatPage = QWidget() - self.cheatLayout = QGridLayout() + self.cheatLayout = QVBoxLayout() self.cheatPage.setLayout(self.cheatLayout) + self.cheat_options = CheatSettingsBox(self.game, self.applySettings) + self.cheatLayout.addWidget(self.cheat_options) + self.moneyCheatBox = QGroupBox("Money Cheat") self.moneyCheatBox.setAlignment(Qt.AlignTop) self.moneyCheatBoxLayout = QGridLayout() @@ -281,7 +316,7 @@ class QSettingsWindow(QDialog): btn.setProperty("style", "btn-danger") btn.clicked.connect(self.cheatLambda(amount)) self.moneyCheatBoxLayout.addWidget(btn, i/2, i%2) - self.cheatLayout.addWidget(self.moneyCheatBox, 0, 0) + self.cheatLayout.addWidget(self.moneyCheatBox, stretch=1) def cheatLambda(self, amount): return lambda: self.cheatMoney(amount) @@ -307,8 +342,6 @@ class QSettingsWindow(QDialog): self.game.settings.include_jtac_if_available = self.include_jtac_if_available.isChecked() self.game.settings.jtac_smoke_on = self.jtac_smoke_on.isChecked() - print(self.game.settings.map_coalition_visibility) - self.game.settings.supercarrier = self.supercarrier.isChecked() self.game.settings.perf_red_alert_state = self.red_alert.isChecked() @@ -322,6 +355,8 @@ class QSettingsWindow(QDialog): self.game.settings.perf_culling = self.culling.isChecked() self.game.settings.perf_culling_distance = int(self.culling_distance.value()) + self.game.settings.show_red_ato = self.cheat_options.show_red_ato + GameUpdateSignal.get_instance().updateGame(self.game) def onSelectionChanged(self): From 7aa17e5ad66018f7c5c91c2cb402b48b441f2fe8 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Fri, 16 Oct 2020 13:42:02 -0700 Subject: [PATCH 20/43] Fix package/flight selection signals. Qt helpfully converts None to 0 for us, so use -1 instead of None. --- qt_ui/widgets/map/QLiberationMap.py | 31 ++++++++++++++++++++++++----- qt_ui/windows/GameUpdateSignal.py | 8 ++++---- 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/qt_ui/widgets/map/QLiberationMap.py b/qt_ui/widgets/map/QLiberationMap.py index dde0893e..2c83996c 100644 --- a/qt_ui/widgets/map/QLiberationMap.py +++ b/qt_ui/widgets/map/QLiberationMap.py @@ -62,18 +62,36 @@ class QLiberationMap(QGraphicsView): lambda: self.draw_flight_plans(self.scene()) ) - def update_package_selection(index: Optional[int]) -> None: - self.selected_flight = index, 0 + def update_package_selection(index: int) -> None: + # Optional[int] isn't a valid type for a Qt signal. None will be + # converted to zero automatically. We use -1 to indicate no + # selection. + if index == -1: + self.selected_flight = None + else: + self.selected_flight = index, 0 self.draw_flight_plans(self.scene()) GameUpdateSignal.get_instance().package_selection_changed.connect( update_package_selection ) - def update_flight_selection(index: Optional[int]) -> None: + def update_flight_selection(index: int) -> None: if self.selected_flight is None: - logging.error("Flight was selected with no package selected") + if index != -1: + # We don't know what order update_package_selection and + # update_flight_selection will be called in when the last + # package is removed. If no flight is selected, it's not a + # problem to also have no package selected. + logging.error( + "Flight was selected with no package selected") return + + # Optional[int] isn't a valid type for a Qt signal. None will be + # converted to zero automatically. We use -1 to indicate no + # selection. + if index == -1: + self.selected_flight = self.selected_flight[0], None self.selected_flight = self.selected_flight[0], index self.draw_flight_plans(self.scene()) @@ -259,7 +277,10 @@ class QLiberationMap(QGraphicsView): packages.extend(self.game_model.red_ato_model.packages) for p_idx, package_model in enumerate(packages): for f_idx, flight in enumerate(package_model.flights): - selected = (p_idx, f_idx) == self.selected_flight + if self.selected_flight is None: + selected = False + else: + selected = (p_idx, f_idx) == self.selected_flight if DisplayOptions.flight_paths.only_selected and not selected: continue self.draw_flight_plan(scene, package_model.package, flight, diff --git a/qt_ui/windows/GameUpdateSignal.py b/qt_ui/windows/GameUpdateSignal.py index 3c112952..529a7498 100644 --- a/qt_ui/windows/GameUpdateSignal.py +++ b/qt_ui/windows/GameUpdateSignal.py @@ -24,8 +24,8 @@ class GameUpdateSignal(QObject): debriefingReceived = Signal(DebriefingSignal) flight_paths_changed = Signal() - package_selection_changed = Signal(int) # Optional[int] - flight_selection_changed = Signal(int) # Optional[int] + package_selection_changed = Signal(int) # -1 indicates no selection. + flight_selection_changed = Signal(int) # -1 indicates no selection. def __init__(self): super(GameUpdateSignal, self).__init__() @@ -33,11 +33,11 @@ class GameUpdateSignal(QObject): def select_package(self, index: Optional[int]) -> None: # noinspection PyUnresolvedReferences - self.package_selection_changed.emit(index) + self.package_selection_changed.emit(-1 if index is None else index) def select_flight(self, index: Optional[int]) -> None: # noinspection PyUnresolvedReferences - self.flight_selection_changed.emit(index) + self.flight_selection_changed.emit(-1 if index is None else index) def redraw_flight_paths(self) -> None: # noinspection PyUnresolvedReferences From 49b6951ac35d45bfd334cf146b02b7c455166b36 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Fri, 16 Oct 2020 18:25:25 -0700 Subject: [PATCH 21/43] Generate weather conditions at turn start. Weather and exact time of day information is helpful during mission planning, so generate it at the start of the turn rather than at takeoff time. Another advantage aside from planning is that we can now use the wind information to set carrier headings and takeoff runways appropriately. --- game/event/event.py | 2 - game/game.py | 24 +++- game/operation/operation.py | 13 +- game/weather.py | 183 ++++++++++++++++++++++++++++ gen/environmentgen.py | 163 ++++--------------------- qt_ui/widgets/QTopPanel.py | 2 +- qt_ui/widgets/QTurnCounter.py | 45 ++++--- qt_ui/widgets/map/QLiberationMap.py | 5 +- 8 files changed, 266 insertions(+), 171 deletions(-) create mode 100644 game/weather.py diff --git a/game/event/event.py b/game/event/event.py index 0af3852c..8f7ac1b8 100644 --- a/game/event/event.py +++ b/game/event/event.py @@ -12,7 +12,6 @@ from game import db, persistency from game.debriefing import Debriefing from game.infos.information import Information from game.operation.operation import Operation -from gen.environmentgen import EnvironmentSettings from gen.ground_forces.combat_stance import CombatStance from theater import ControlPoint from theater.start_generator import generate_airbase_defense_group @@ -42,7 +41,6 @@ class Event: operation = None # type: Operation difficulty = 1 # type: int - environment_settings = None # type: EnvironmentSettings BONUS_BASE = 5 def __init__(self, game, from_cp: ControlPoint, target_cp: ControlPoint, location: Point, attacker_name: str, defender_name: str): diff --git a/game/game.py b/game/game.py index dcac8220..e8e38662 100644 --- a/game/game.py +++ b/game/game.py @@ -2,7 +2,7 @@ import logging import math import random import sys -from datetime import datetime, timedelta +from datetime import date, datetime, timedelta from typing import Any, Dict, List from dcs.action import Coalition @@ -28,6 +28,7 @@ from .event.event import Event, UnitsDeliveryEvent from .event.frontlineattack import FrontlineAttackEvent from .infos.information import Information from .settings import Settings +from .weather import Conditions, TimeOfDay COMMISION_UNIT_VARIETY = 4 COMMISION_LIMITS_SCALE = 1.5 @@ -78,7 +79,7 @@ class Game: self.enemy_name = enemy_name self.enemy_country = db.FACTIONS[enemy_name]["country"] self.turn = 0 - self.date = datetime(start_date.year, start_date.month, start_date.day) + self.date = date(start_date.year, start_date.month, start_date.day) self.game_stats = GameStats() self.game_stats.update(self) self.ground_planners: Dict[int, GroundPlanner] = {} @@ -91,6 +92,8 @@ class Game: self.current_unit_id = 0 self.current_group_id = 0 + self.conditions = self.generate_conditions() + self.blue_ato = AirTaskingOrder() self.red_ato = AirTaskingOrder() @@ -101,6 +104,9 @@ class Game: self.sanitize_sides() self.on_load() + def generate_conditions(self) -> Conditions: + return Conditions.generate(self.theater, self.date, + self.current_turn_time_of_day, self.settings) def sanitize_sides(self): """ @@ -218,6 +224,12 @@ class Game: def on_load(self) -> None: ObjectiveDistanceCache.set_theater(self.theater) + # Save game compatibility. + + # TODO: Remove in 2.3. + if not hasattr(self, "conditions"): + self.conditions = self.generate_conditions() + def pass_turn(self, no_action=False): logging.info("Pass turn") self.informations.append(Information("End of turn #" + str(self.turn), "-" * 40, 0)) @@ -252,6 +264,8 @@ class Game: for cp in self.theater.controlpoints: self.aircraft_inventory.set_from_control_point(cp) + self.conditions = self.generate_conditions() + # Plan flights & combat for next turn self.__culling_points = self.compute_conflicts_position() self.ground_planners = {} @@ -340,11 +354,11 @@ class Game: self.informations.append(info) @property - def current_turn_daytime(self): - return ["dawn", "day", "dusk", "night"][self.turn % 4] + def current_turn_time_of_day(self) -> TimeOfDay: + return list(TimeOfDay)[self.turn % 4] @property - def current_day(self): + def current_day(self) -> date: return self.date + timedelta(days=self.turn // 4) def next_unit_id(self): diff --git a/game/operation/operation.py b/game/operation/operation.py index ecc82e51..1f12d623 100644 --- a/game/operation/operation.py +++ b/game/operation/operation.py @@ -21,7 +21,7 @@ from gen.airsupportgen import AirSupport, AirSupportConflictGenerator from gen.armor import GroundConflictGenerator, JtacInfo from gen.beacons import load_beacons_for_terrain from gen.briefinggen import BriefingGenerator -from gen.environmentgen import EnviromentGenerator +from gen.environmentgen import EnvironmentGenerator from gen.forcedoptionsgen import ForcedOptionsGenerator from gen.groundobjectsgen import GroundObjectsGenerator from gen.kneeboard import KneeboardGenerator @@ -45,7 +45,6 @@ class Operation: triggersgen = None # type: TriggersGenerator airsupportgen = None # type: AirSupportConflictGenerator visualgen = None # type: VisualGenerator - envgen = None # type: EnviromentGenerator groundobjectgen = None # type: GroundObjectsGenerator briefinggen = None # type: BriefingGenerator forcedoptionsgen = None # type: ForcedOptionsGenerator @@ -162,13 +161,9 @@ class Operation: for frequency in unique_map_frequencies: radio_registry.reserve(frequency) - # Generate meteo - envgen = EnviromentGenerator(self.current_mission, self.conflict, - self.game) - if self.environment_settings is None: - self.environment_settings = envgen.generate() - else: - envgen.load(self.environment_settings) + # Set mission time and weather conditions. + EnvironmentGenerator(self.current_mission, + self.game.conditions).generate() # Generate ground object first diff --git a/game/weather.py b/game/weather.py new file mode 100644 index 00000000..a9ac5141 --- /dev/null +++ b/game/weather.py @@ -0,0 +1,183 @@ +from __future__ import annotations + +import datetime +import logging +import random +from dataclasses import dataclass +from enum import Enum +from typing import Optional + +from dcs.weather import Weather as PydcsWeather, Wind + +from game.settings import Settings +from theater import ConflictTheater + + +class TimeOfDay(Enum): + Dawn = "dawn" + Day = "day" + Dusk = "dusk" + Night = "night" + + +@dataclass(frozen=True) +class WindConditions: + at_0m: Wind + at_2000m: Wind + at_8000m: Wind + + +@dataclass(frozen=True) +class Clouds: + base: int + density: int + thickness: int + precipitation: PydcsWeather.Preceptions + + +@dataclass(frozen=True) +class Fog: + visibility: int + thickness: int + + +class Weather: + def __init__(self) -> None: + self.clouds = self.generate_clouds() + self.fog = self.generate_fog() + self.wind = self.generate_wind() + + def generate_clouds(self) -> Optional[Clouds]: + raise NotImplementedError + + def generate_fog(self) -> Optional[Fog]: + if random.randrange(5) != 0: + return None + return Fog( + visibility=random.randint(2500, 5000), + thickness=random.randint(100, 500) + ) + + def generate_wind(self) -> WindConditions: + raise NotImplementedError + + @staticmethod + def random_wind(minimum: int, maximum) -> WindConditions: + wind_direction = random.randint(0, 360) + at_0m_factor = 1 + at_2000m_factor = 2 + at_8000m_factor = 3 + base_wind = random.randint(minimum, maximum) + + return WindConditions( + # Always some wind to make the smoke move a bit. + at_0m=Wind(wind_direction, min(1, base_wind * at_0m_factor)), + at_2000m=Wind(wind_direction, base_wind * at_2000m_factor), + at_8000m=Wind(wind_direction, base_wind * at_8000m_factor) + ) + + @staticmethod + def random_cloud_base() -> int: + return random.randint(2000, 3000) + + @staticmethod + def random_cloud_thickness() -> int: + return random.randint(100, 400) + + +class ClearSkies(Weather): + def generate_clouds(self) -> Optional[Clouds]: + return None + + def generate_fog(self) -> Optional[Fog]: + return None + + def generate_wind(self) -> WindConditions: + return self.random_wind(0, 0) + + +class Cloudy(Weather): + def generate_clouds(self) -> Optional[Clouds]: + return Clouds( + base=self.random_cloud_base(), + density=random.randint(1, 8), + thickness=self.random_cloud_thickness(), + precipitation=PydcsWeather.Preceptions.None_ + ) + + def generate_wind(self) -> WindConditions: + return self.random_wind(0, 4) + + +class Raining(Weather): + def generate_clouds(self) -> Optional[Clouds]: + return Clouds( + base=self.random_cloud_base(), + density=random.randint(5, 8), + thickness=self.random_cloud_thickness(), + precipitation=PydcsWeather.Preceptions.Rain + ) + + def generate_wind(self) -> WindConditions: + return self.random_wind(0, 6) + + +class Thunderstorm(Weather): + def generate_clouds(self) -> Optional[Clouds]: + return Clouds( + base=self.random_cloud_base(), + density=random.randint(9, 10), + thickness=self.random_cloud_thickness(), + precipitation=PydcsWeather.Preceptions.Thunderstorm + ) + + def generate_wind(self) -> WindConditions: + return self.random_wind(0, 8) + + +@dataclass +class Conditions: + time_of_day: TimeOfDay + start_time: datetime.datetime + weather: Weather + + @classmethod + def generate(cls, theater: ConflictTheater, day: datetime.date, + time_of_day: TimeOfDay, settings: Settings) -> Conditions: + return cls( + time_of_day=time_of_day, + start_time=cls.generate_start_time( + theater, day, time_of_day, settings.night_disabled + ), + weather=cls.generate_weather() + ) + + @classmethod + def generate_start_time(cls, theater: ConflictTheater, day: datetime.date, + time_of_day: TimeOfDay, + night_disabled: bool) -> datetime.datetime: + if night_disabled: + logging.info("Skip Night mission due to user settings") + time_range = { + TimeOfDay.Dawn: (8, 9), + TimeOfDay.Day: (10, 12), + TimeOfDay.Dusk: (12, 14), + TimeOfDay.Night: (14, 17), + }[time_of_day] + else: + time_range = theater.daytime_map[time_of_day.value] + + time = datetime.time(hour=random.randint(*time_range)) + return datetime.datetime.combine(day, time) + + @classmethod + def generate_weather(cls) -> Weather: + chances = { + Thunderstorm: 1, + Raining: 20, + Cloudy: 60, + ClearSkies: 20, + } + weather_type = random.choices(list(chances.keys()), + weights=list(chances.values()))[0] + return weather_type() diff --git a/gen/environmentgen.py b/gen/environmentgen.py index 57d70452..7712cea5 100644 --- a/gen/environmentgen.py +++ b/gen/environmentgen.py @@ -1,147 +1,36 @@ -import logging -import random -from datetime import timedelta +from typing import Optional from dcs.mission import Mission -from dcs.weather import Weather, Wind -from .conflictgen import Conflict - -WEATHER_CLOUD_BASE = 2000, 3000 -WEATHER_CLOUD_DENSITY = 1, 8 -WEATHER_CLOUD_THICKNESS = 100, 400 -WEATHER_CLOUD_BASE_MIN = 1600 - -WEATHER_FOG_CHANCE = 20 -WEATHER_FOG_VISIBILITY = 2500, 5000 -WEATHER_FOG_THICKNESS = 100, 500 - -RANDOM_TIME = { - "night": 7, - "dusk": 40, - "dawn": 40, - "day": 100, -} - -RANDOM_WEATHER = { - 1: 0, # thunderstorm - 2: 20, # rain - 3: 80, # clouds - 4: 100, # clear -} +from game.weather import Clouds, Fog, Conditions, WindConditions -class EnvironmentSettings: - weather_dict = None - start_time = None - - -class EnviromentGenerator: - def __init__(self, mission: Mission, conflict: Conflict, game): +class EnvironmentGenerator: + def __init__(self, mission: Mission, conditions: Conditions) -> None: self.mission = mission - self.conflict = conflict - self.game = game + self.conditions = conditions - def _gen_time(self): + def set_clouds(self, clouds: Optional[Clouds]) -> None: + if clouds is None: + return + self.mission.weather.clouds_base = clouds.base + self.mission.weather.clouds_thickness = clouds.thickness + self.mission.weather.clouds_density = clouds.density + self.mission.weather.clouds_iprecptns = clouds.precipitation - start_time = self.game.current_day + def set_fog(self, fog: Optional[Fog]) -> None: + if fog is None: + return + self.mission.weather.fog_visibility = fog.visibility + self.mission.weather.fog_thickness = fog.thickness - daytime = self.game.current_turn_daytime - logging.info("Mission time will be {}".format(daytime)) - if self.game.settings.night_disabled: - logging.info("Skip Night mission due to user settings") - if daytime == "dawn": - time_range = (8, 9) - elif daytime == "day": - time_range = (10, 12) - elif daytime == "dusk": - time_range = (12, 14) - elif daytime == "night": - time_range = (14, 17) - else: - time_range = (10, 12) - else: - time_range = self.game.theater.daytime_map[daytime] - - start_time += timedelta(hours=random.randint(*time_range)) - - logging.info("time - {}, slot - {}, night skipped - {}".format( - str(start_time), - str(time_range), - self.game.settings.night_disabled)) - - self.mission.start_time = start_time - - def _generate_wind(self, wind_speed, wind_direction=None): - # wind - if not wind_direction: - wind_direction = random.randint(0, 360) - - self.mission.weather.wind_at_ground = Wind(wind_direction, wind_speed) - self.mission.weather.wind_at_2000 = Wind(wind_direction, wind_speed * 2) - self.mission.weather.wind_at_8000 = Wind(wind_direction, wind_speed * 3) - - def _generate_base_weather(self): - # clouds - self.mission.weather.clouds_base = random.randint(*WEATHER_CLOUD_BASE) - self.mission.weather.clouds_density = random.randint(*WEATHER_CLOUD_DENSITY) - self.mission.weather.clouds_thickness = random.randint(*WEATHER_CLOUD_THICKNESS) - - # wind - self._generate_wind(random.randint(0, 4)) - - # fog - if random.randint(0, 100) < WEATHER_FOG_CHANCE: - self.mission.weather.fog_visibility = random.randint(*WEATHER_FOG_VISIBILITY) - self.mission.weather.fog_thickness = random.randint(*WEATHER_FOG_THICKNESS) - - def _gen_random_weather(self): - weather_type = None - for k, v in RANDOM_WEATHER.items(): - if random.randint(0, 100) <= v: - weather_type = k - break - - logging.info("generated weather {}".format(weather_type)) - if weather_type == 1: - # thunderstorm - self._generate_base_weather() - self._generate_wind(random.randint(0, 8)) - - self.mission.weather.clouds_density = random.randint(9, 10) - self.mission.weather.clouds_iprecptns = Weather.Preceptions.Thunderstorm - elif weather_type == 2: - # rain - self._generate_base_weather() - self.mission.weather.clouds_density = random.randint(5, 8) - self.mission.weather.clouds_iprecptns = Weather.Preceptions.Rain - - self._generate_wind(random.randint(0, 6)) - elif weather_type == 3: - # clouds - self._generate_base_weather() - elif weather_type == 4: - # clear - pass - - if self.mission.weather.clouds_density > 0: - # sometimes clouds are randomized way too low and need to be fixed - self.mission.weather.clouds_base = max(self.mission.weather.clouds_base, WEATHER_CLOUD_BASE_MIN) - - if self.mission.weather.wind_at_ground.speed == 0: - # frontline smokes look silly w/o any wind - self._generate_wind(1) - - def generate(self) -> EnvironmentSettings: - self._gen_time() - self._gen_random_weather() - - settings = EnvironmentSettings() - settings.start_time = self.mission.start_time - settings.weather_dict = self.mission.weather.dict() - return settings - - def load(self, settings: EnvironmentSettings): - self.mission.start_time = settings.start_time - self.mission.weather.load_from_dict(settings.weather_dict) + def set_wind(self, wind: WindConditions) -> None: + self.mission.weather.wind_at_ground = wind.at_0m + self.mission.weather.wind_at_2000 = wind.at_2000m + self.mission.weather.wind_at_8000 = wind.at_8000m + def generate(self): + self.mission.start_time = self.conditions.start_time + self.set_clouds(self.conditions.weather.clouds) + self.set_fog(self.conditions.weather.fog) + self.set_wind(self.conditions.weather.wind) diff --git a/qt_ui/widgets/QTopPanel.py b/qt_ui/widgets/QTopPanel.py index d115c98b..dadbee0d 100644 --- a/qt_ui/widgets/QTopPanel.py +++ b/qt_ui/widgets/QTopPanel.py @@ -97,7 +97,7 @@ class QTopPanel(QFrame): if game is None: return - self.turnCounter.setCurrentTurn(game.turn, game.current_day) + self.turnCounter.setCurrentTurn(game.turn, game.conditions) self.budgetBox.setGame(game) self.factionsInfos.setGame(game) diff --git a/qt_ui/widgets/QTurnCounter.py b/qt_ui/widgets/QTurnCounter.py index f7e6fd88..a26112e1 100644 --- a/qt_ui/widgets/QTurnCounter.py +++ b/qt_ui/widgets/QTurnCounter.py @@ -1,7 +1,8 @@ import datetime -from PySide2.QtWidgets import QLabel, QHBoxLayout, QGroupBox +from PySide2.QtWidgets import QLabel, QHBoxLayout, QGroupBox, QVBoxLayout +from game.weather import Conditions, TimeOfDay import qt_ui.uiconstants as CONST @@ -13,23 +14,37 @@ class QTurnCounter(QGroupBox): def __init__(self): super(QTurnCounter, self).__init__("Turn") - self.icons = [CONST.ICONS["Dawn"], CONST.ICONS["Day"], CONST.ICONS["Dusk"], CONST.ICONS["Night"]] - - self.daytime_icon = QLabel() - self.daytime_icon.setPixmap(self.icons[0]) - self.turn_info = QLabel() + self.icons = { + TimeOfDay.Dawn: CONST.ICONS["Dawn"], + TimeOfDay.Day: CONST.ICONS["Day"], + TimeOfDay.Dusk: CONST.ICONS["Dusk"], + TimeOfDay.Night: CONST.ICONS["Night"], + } self.layout = QHBoxLayout() - self.layout.addWidget(self.daytime_icon) - self.layout.addWidget(self.turn_info) self.setLayout(self.layout) - def setCurrentTurn(self, turn: int, current_day: datetime): + self.daytime_icon = QLabel() + self.daytime_icon.setPixmap(self.icons[TimeOfDay.Dawn]) + self.layout.addWidget(self.daytime_icon) + + self.time_column = QVBoxLayout() + self.layout.addLayout(self.time_column) + + self.date_display = QLabel() + self.time_column.addWidget(self.date_display) + + self.time_display = QLabel() + self.time_column.addWidget(self.time_display) + + def setCurrentTurn(self, turn: int, conditions: Conditions) -> None: + """Sets the turn information display. + + :arg turn Current turn number. + :arg conditions Current time and weather conditions. """ - Set the money amount to display - :arg turn Current turn number - :arg current_day Current day - """ - self.daytime_icon.setPixmap(self.icons[turn % 4]) - self.turn_info.setText(current_day.strftime("%d %b %Y")) + self.daytime_icon.setPixmap(self.icons[conditions.time_of_day]) + self.date_display.setText(conditions.start_time.strftime("%d %b %Y")) + self.time_display.setText( + conditions.start_time.strftime("%H:%M:%S Local")) self.setTitle("Turn " + str(turn + 1)) diff --git a/qt_ui/widgets/map/QLiberationMap.py b/qt_ui/widgets/map/QLiberationMap.py index 2c83996c..7344275f 100644 --- a/qt_ui/widgets/map/QLiberationMap.py +++ b/qt_ui/widgets/map/QLiberationMap.py @@ -21,6 +21,7 @@ from game import Game, db from game.data.aaa_db import AAA_UNITS from game.data.radar_db import UNITS_WITH_RADAR from game.utils import meter_to_feet +from game.weather import TimeOfDay from gen import Conflict, PackageWaypointTiming from gen.ato import Package from gen.flights.flight import Flight, FlightWaypoint, FlightWaypointType @@ -509,9 +510,9 @@ class QLiberationMap(QGraphicsView): scene.addPixmap(bg) # Apply graphical effects to simulate current daytime - if self.game.current_turn_daytime == "day": + if self.game.current_turn_time_of_day == TimeOfDay.Day: pass - elif self.game.current_turn_daytime == "night": + elif self.game.current_turn_time_of_day == TimeOfDay.Night: ov = QPixmap(bg.width(), bg.height()) ov.fill(CONST.COLORS["night_overlay"]) overlay = scene.addPixmap(ov) From 002f55dc04e8e07884707dd99fa8f0cb3e512521 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Sat, 17 Oct 2020 13:31:52 -0700 Subject: [PATCH 22/43] Update the map from the main window. This guarantees that we update the map *after* updating the model that the map uses to draw flight plans. Without this, after creating a new game we'd redraw the previous game's flight plans because the model hadn't been updated by the time the map was. Fixes https://github.com/Khopa/dcs_liberation/issues/212 --- qt_ui/widgets/map/QLiberationMap.py | 9 ++++----- qt_ui/windows/QLiberationWindow.py | 5 ++++- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/qt_ui/widgets/map/QLiberationMap.py b/qt_ui/widgets/map/QLiberationMap.py index 7344275f..6ccde6da 100644 --- a/qt_ui/widgets/map/QLiberationMap.py +++ b/qt_ui/widgets/map/QLiberationMap.py @@ -56,7 +56,6 @@ class QLiberationMap(QGraphicsView): self.factor = 1 self.factorized = 1 self.init_scene() - self.connectSignals() self.setGame(game_model.game) GameUpdateSignal.get_instance().flight_paths_changed.connect( @@ -109,9 +108,6 @@ class QLiberationMap(QGraphicsView): self.setFrameShape(QFrame.NoFrame) self.setDragMode(QGraphicsView.ScrollHandDrag) - def connectSignals(self): - GameUpdateSignal.get_instance().gameupdated.connect(self.setGame) - def setGame(self, game: Optional[Game]): self.game = game logging.debug("Reloading Map Canvas") @@ -263,7 +259,7 @@ class QLiberationMap(QGraphicsView): text.setDefaultTextColor(Qt.white) text.setPos(pos[0] + CONST.CP_SIZE + 1, pos[1] - CONST.CP_SIZE / 2 + 1) - def draw_flight_plans(self, scene) -> None: + def clear_flight_paths(self, scene: QGraphicsScene) -> None: for item in self.flight_path_items: try: scene.removeItem(item) @@ -271,6 +267,9 @@ class QLiberationMap(QGraphicsView): # Something may have caused those items to already be removed. pass self.flight_path_items.clear() + + def draw_flight_plans(self, scene: QGraphicsScene) -> None: + self.clear_flight_paths(scene) if DisplayOptions.flight_paths.hide: return packages = list(self.game_model.ato_model.packages) diff --git a/qt_ui/windows/QLiberationWindow.py b/qt_ui/windows/QLiberationWindow.py index db5caa10..99783d11 100644 --- a/qt_ui/windows/QLiberationWindow.py +++ b/qt_ui/windows/QLiberationWindow.py @@ -43,6 +43,7 @@ class QLiberationWindow(QMainWindow): Dialog.set_game(self.game_model) self.ato_panel = None self.info_panel = None + self.liberation_map = None self.setGame(persistency.restore_game()) self.setGeometry(300, 100, 270, 100) @@ -224,9 +225,11 @@ class QLiberationWindow(QMainWindow): if game is not None: game.on_load() self.game = game - if self.info_panel: + if self.info_panel is not None: self.info_panel.setGame(game) self.game_model.set(self.game) + if self.liberation_map is not None: + self.liberation_map.setGame(game) def showAboutDialog(self): text = "

DCS Liberation " + CONST.VERSION_STRING + "

" + \ From cace523aa835448404c00550b183a3e1713839d1 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Sat, 17 Oct 2020 14:10:36 -0700 Subject: [PATCH 23/43] Avoid crash for custom/empty flight plans. Fixes https://github.com/Khopa/dcs_liberation/issues/210 --- gen/flights/flightplan.py | 4 ++-- gen/flights/traveltime.py | 13 +++++++++++-- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/gen/flights/flightplan.py b/gen/flights/flightplan.py index 8619fa3f..0a3a3174 100644 --- a/gen/flights/flightplan.py +++ b/gen/flights/flightplan.py @@ -103,8 +103,8 @@ class FlightPlanBuilder: logging.error( "Troop transport flight plan generation not implemented" ) - except InvalidObjectiveLocation as ex: - logging.error(f"Could not create flight plan: {ex}") + except InvalidObjectiveLocation: + logging.exception(f"Could not create flight plan") def regenerate_package_waypoints(self) -> None: ingress_point = self._ingress_point() diff --git a/gen/flights/traveltime.py b/gen/flights/traveltime.py index 17879616..7340715e 100644 --- a/gen/flights/traveltime.py +++ b/gen/flights/traveltime.py @@ -42,8 +42,17 @@ class GroundSpeed: for flight in package.flights: waypoint = flight.waypoint_with_type(IP_TYPES) if waypoint is None: - logging.error(f"Could not find ingress point for {flight}") - continue + logging.error(f"Could not find ingress point for {flight}.") + if flight.points: + logging.warning( + "Using first waypoint for mission altitude.") + waypoint = flight.points[0] + else: + logging.warning( + "Flight has no waypoints. Assuming mission altitude " + "of 25000 feet.") + waypoint = FlightWaypoint(FlightWaypointType.NAV, 0, 0, + 25000) speeds.add(GroundSpeed.for_flight(flight, waypoint.alt)) return min(speeds) From 3d41eb1ab4df5e1207ea2579a9c6cf5019f15caa Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Sat, 17 Oct 2020 14:32:09 -0700 Subject: [PATCH 24/43] Clean up CAP types. Stop using "CAP". Use BARCAP or TARCAP instead. TARCAP no longer allowed anywhere but front lines, since that's all we have mission planning for right now. Later will add TARCAP and BARCAP for all objective types with different timing profiles. Part two of the fix for https://github.com/Khopa/dcs_liberation/issues/210. --- gen/aircraft.py | 2 +- gen/flights/ai_flight_planner.py | 12 ++++------ gen/flights/flight.py | 2 +- gen/flights/flightplan.py | 4 ++-- gen/flights/traveltime.py | 9 ++++---- qt_ui/widgets/combos/QFlightTypeComboBox.py | 23 +++++++++++-------- .../flight/waypoints/QFlightWaypointTab.py | 10 +++++--- 7 files changed, 32 insertions(+), 30 deletions(-) diff --git a/gen/aircraft.py b/gen/aircraft.py index 070670f6..88824e36 100644 --- a/gen/aircraft.py +++ b/gen/aircraft.py @@ -997,7 +997,7 @@ class AircraftConflictGenerator: flight: Flight, timing: PackageWaypointTiming, dynamic_runways: Dict[str, RunwayData]) -> None: flight_type = flight.flight_type - if flight_type in [FlightType.CAP, FlightType.BARCAP, FlightType.TARCAP, + if flight_type in [FlightType.BARCAP, FlightType.TARCAP, FlightType.INTERCEPTION]: self.configure_cap(group, flight, dynamic_runways) elif flight_type in [FlightType.CAS, FlightType.BAI]: diff --git a/gen/flights/ai_flight_planner.py b/gen/flights/ai_flight_planner.py index 01f5d1b4..a1473433 100644 --- a/gen/flights/ai_flight_planner.py +++ b/gen/flights/ai_flight_planner.py @@ -131,7 +131,7 @@ class AircraftAllocator: @staticmethod def preferred_aircraft_for_task(task: FlightType) -> List[Type[FlyingType]]: - cap_missions = (FlightType.BARCAP, FlightType.CAP, FlightType.TARCAP) + cap_missions = (FlightType.BARCAP, FlightType.TARCAP) if task in cap_missions: return CAP_PREFERRED elif task == FlightType.CAS: @@ -147,7 +147,7 @@ class AircraftAllocator: @staticmethod def capable_aircraft_for_task(task: FlightType) -> List[Type[FlyingType]]: - cap_missions = (FlightType.BARCAP, FlightType.CAP, FlightType.TARCAP) + cap_missions = (FlightType.BARCAP, FlightType.TARCAP) if task in cap_missions: return CAP_CAPABLE elif task == FlightType.CAS: @@ -403,7 +403,7 @@ class CoalitionMissionPlanner: # Find friendly CPs within 100 nmi from an enemy airfield, plan CAP. for cp in self.objective_finder.vulnerable_control_points(): yield ProposedMission(cp, [ - ProposedFlight(FlightType.CAP, 2, self.MAX_CAP_RANGE), + ProposedFlight(FlightType.BARCAP, 2, self.MAX_CAP_RANGE), ]) # Find front lines, plan CAP. @@ -492,11 +492,7 @@ class CoalitionMissionPlanner: error = random.randint(-margin, margin) yield max(0, time + error) - dca_types = ( - FlightType.BARCAP, - FlightType.CAP, - FlightType.INTERCEPTION, - ) + dca_types = (FlightType.BARCAP, FlightType.INTERCEPTION) non_dca_packages = [p for p in self.ato.packages if p.primary_task not in dca_types] diff --git a/gen/flights/flight.py b/gen/flights/flight.py index 85fe4c17..bd879daa 100644 --- a/gen/flights/flight.py +++ b/gen/flights/flight.py @@ -8,7 +8,7 @@ from theater.controlpoint import ControlPoint, MissionTarget class FlightType(Enum): - CAP = 0 + CAP = 0 # Do not use. Use BARCAP or TARCAP. TARCAP = 1 BARCAP = 2 CAS = 3 diff --git a/gen/flights/flightplan.py b/gen/flights/flightplan.py index 0a3a3174..9bc06473 100644 --- a/gen/flights/flightplan.py +++ b/gen/flights/flightplan.py @@ -69,8 +69,6 @@ class FlightPlanBuilder: logging.error("BAI flight plan generation not implemented") elif task == FlightType.BARCAP: self.generate_barcap(flight) - elif task == FlightType.CAP: - self.generate_barcap(flight) elif task == FlightType.CAS: self.generate_cas(flight) elif task == FlightType.DEAD: @@ -103,6 +101,8 @@ class FlightPlanBuilder: logging.error( "Troop transport flight plan generation not implemented" ) + else: + logging.error(f"Unsupported task type: {task.name}") except InvalidObjectiveLocation: logging.exception(f"Could not create flight plan") diff --git a/gen/flights/traveltime.py b/gen/flights/traveltime.py index 7340715e..a4690f1a 100644 --- a/gen/flights/traveltime.py +++ b/gen/flights/traveltime.py @@ -19,7 +19,6 @@ from gen.flights.flight import ( CAP_DURATION = 30 # Minutes -CAP_TYPES = (FlightType.BARCAP, FlightType.CAP) INGRESS_TYPES = { FlightWaypointType.INGRESS_CAS, @@ -151,7 +150,7 @@ class TotEstimator: # Takeoff immediately. return 0 - if self.package.primary_task in CAP_TYPES: + if self.package.primary_task == FlightType.BARCAP: start_time = self.timing.race_track_start else: start_time = self.timing.join @@ -184,7 +183,7 @@ class TotEstimator: # the package. return 0 - if self.package.primary_task in CAP_TYPES: + if self.package.primary_task == FlightType.BARCAP: # The racetrack start *is* the target. The package target is the # protected objective. time_to_target = 0 @@ -278,14 +277,14 @@ class PackageWaypointTiming: @property def race_track_start(self) -> int: - if self.package.primary_task in CAP_TYPES: + if self.package.primary_task == FlightType.BARCAP: return self.package.time_over_target else: return self.ingress @property def race_track_end(self) -> int: - if self.package.primary_task in CAP_TYPES: + if self.package.primary_task == FlightType.BARCAP: return self.target + CAP_DURATION * 60 else: return self.egress diff --git a/qt_ui/widgets/combos/QFlightTypeComboBox.py b/qt_ui/widgets/combos/QFlightTypeComboBox.py index 9577b26c..429ff902 100644 --- a/qt_ui/widgets/combos/QFlightTypeComboBox.py +++ b/qt_ui/widgets/combos/QFlightTypeComboBox.py @@ -19,7 +19,6 @@ class QFlightTypeComboBox(QComboBox): COMMON_ENEMY_MISSIONS = [ FlightType.ESCORT, - FlightType.TARCAP, FlightType.SEAD, FlightType.DEAD, # TODO: FlightType.ELINT, @@ -27,42 +26,46 @@ class QFlightTypeComboBox(QComboBox): # TODO: FlightType.RECON, ] - FRIENDLY_AIRBASE_MISSIONS = [ - FlightType.CAP, - # TODO: FlightType.INTERCEPTION - # TODO: FlightType.LOGISTICS + COMMON_FRIENDLY_MISSIONS = [ + FlightType.BARCAP, ] + FRIENDLY_AIRBASE_MISSIONS = [ + # TODO: FlightType.INTERCEPTION + # TODO: FlightType.LOGISTICS + ] + COMMON_FRIENDLY_MISSIONS + FRIENDLY_CARRIER_MISSIONS = [ - FlightType.BARCAP, # TODO: FlightType.INTERCEPTION # TODO: Buddy tanking for the A-4? # TODO: Rescue chopper? # TODO: Inter-ship logistics? - ] + ] + COMMON_FRIENDLY_MISSIONS ENEMY_CARRIER_MISSIONS = [ FlightType.ESCORT, - FlightType.TARCAP, + FlightType.BARCAP, # TODO: FlightType.ANTISHIP ] ENEMY_AIRBASE_MISSIONS = [ + FlightType.BARCAP, # TODO: FlightType.STRIKE ] + COMMON_ENEMY_MISSIONS FRIENDLY_GROUND_OBJECT_MISSIONS = [ - FlightType.CAP, # TODO: FlightType.LOGISTICS # TODO: FlightType.TROOP_TRANSPORT - ] + ] + COMMON_FRIENDLY_MISSIONS ENEMY_GROUND_OBJECT_MISSIONS = [ + FlightType.BARCAP, FlightType.STRIKE, ] + COMMON_ENEMY_MISSIONS FRONT_LINE_MISSIONS = [ FlightType.CAS, + FlightType.TARCAP, # TODO: FlightType.TROOP_TRANSPORT # TODO: FlightType.EVAC ] + COMMON_ENEMY_MISSIONS diff --git a/qt_ui/windows/mission/flight/waypoints/QFlightWaypointTab.py b/qt_ui/windows/mission/flight/waypoints/QFlightWaypointTab.py index 98064b5c..21a85a84 100644 --- a/qt_ui/windows/mission/flight/waypoints/QFlightWaypointTab.py +++ b/qt_ui/windows/mission/flight/waypoints/QFlightWaypointTab.py @@ -54,6 +54,7 @@ class QFlightWaypointTab(QFrame): rlayout.addWidget(QLabel("Generator :")) rlayout.addWidget(QLabel("AI compatible")) + # TODO: Filter by objective type. self.recreate_buttons.clear() recreate_types = [ FlightType.CAS, @@ -137,13 +138,16 @@ class QFlightWaypointTab(QFrame): QMessageBox.Yes ) if result == QMessageBox.Yes: - # TODO: These should all be just CAP. + # TODO: Should be buttons for both BARCAP and TARCAP. + # BARCAP and TARCAP behave differently. TARCAP arrives a few minutes + # ahead of the rest of the package and stays until the package + # departs, whereas BARCAP usually isn't part of a strike package and + # has a fixed mission time. if task == FlightType.CAP: if isinstance(self.package.target, FrontLine): task = FlightType.TARCAP elif isinstance(self.package.target, ControlPoint): - if self.package.target.is_fleet: - task = FlightType.BARCAP + task = FlightType.BARCAP self.flight.flight_type = task self.planner.populate_flight_plan(self.flight) self.flight_waypoint_list.update_list() From 2269cf0f08920909d0b16962dd7bae9c6df4ab20 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Sat, 17 Oct 2020 16:11:28 -0700 Subject: [PATCH 25/43] Show objective value per turn in the tooltip. --- qt_ui/widgets/map/QMapGroundObject.py | 29 ++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/qt_ui/widgets/map/QMapGroundObject.py b/qt_ui/widgets/map/QMapGroundObject.py index af0789a8..a7d857f3 100644 --- a/qt_ui/widgets/map/QMapGroundObject.py +++ b/qt_ui/widgets/map/QMapGroundObject.py @@ -7,6 +7,7 @@ from PySide2.QtWidgets import QGraphicsItem import qt_ui.uiconstants as const from game import Game from game.data.building_data import FORTIFICATION_BUILDINGS +from game.db import REWARDS from qt_ui.windows.groundobject.QGroundObjectMenu import QGroundObjectMenu from theater import ControlPoint, TheaterGroundObject from .QMapObject import QMapObject @@ -27,7 +28,14 @@ class QMapGroundObject(QMapObject): self.buildings = buildings if buildings is not None else [] self.setFlag(QGraphicsItem.ItemIgnoresTransformations, False) self.ground_object_dialog: Optional[QGroundObjectMenu] = None + self.setToolTip(self.tooltip) + @property + def tooltip(self) -> str: + lines = [ + f"[{self.ground_object.obj_name}]", + f"${self.production_per_turn} per turn", + ] if self.ground_object.groups: units = {} for g in self.ground_object.groups: @@ -36,16 +44,23 @@ class QMapGroundObject(QMapObject): units[u.type] = units[u.type]+1 else: units[u.type] = 1 - tooltip = "[" + self.ground_object.obj_name + "]" + "\n" + for unit in units.keys(): - tooltip = tooltip + str(unit) + "x" + str(units[unit]) + "\n" - self.setToolTip(tooltip[:-1]) + lines.append(f"{unit} x {units[unit]}") else: - tooltip = "[" + self.ground_object.obj_name + "]" + "\n" - for building in buildings: + for building in self.buildings: if not building.is_dead: - tooltip = tooltip + str(building.dcs_identifier) + "\n" - self.setToolTip(tooltip[:-1]) + lines.append(f"{building.dcs_identifier}") + + return "\n".join(lines) + + @property + def production_per_turn(self) -> int: + production = 0 + for g in self.control_point.ground_objects: + if g.category in REWARDS.keys(): + production += REWARDS[g.category] + return production def paint(self, painter, option, widget=None) -> None: player_icons = "_blue" From 8617f48fc2dffd44e9c6ff7a591770b55c18fea2 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Mon, 12 Oct 2020 22:23:24 -0700 Subject: [PATCH 26/43] Sort air unit purchases properly, and by name. Previously we were sorting by task first and price second. Much easier to find things if they're sorted by name (although longer term we should make the sort option selectable). --- .../airfield/QAircraftRecruitmentMenu.py | 32 ++++++++++--------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/qt_ui/windows/basemenu/airfield/QAircraftRecruitmentMenu.py b/qt_ui/windows/basemenu/airfield/QAircraftRecruitmentMenu.py index b679bf7b..2dbba2f3 100644 --- a/qt_ui/windows/basemenu/airfield/QAircraftRecruitmentMenu.py +++ b/qt_ui/windows/basemenu/airfield/QAircraftRecruitmentMenu.py @@ -1,15 +1,16 @@ -from typing import Optional +from typing import Optional, Set from PySide2.QtCore import Qt from PySide2.QtWidgets import ( QFrame, QGridLayout, - QScrollArea, - QVBoxLayout, QHBoxLayout, QLabel, + QScrollArea, + QVBoxLayout, QWidget, ) +from dcs.unittype import UnitType from game.event.event import UnitsDeliveryEvent from qt_ui.models import GameModel @@ -48,26 +49,27 @@ class QAircraftRecruitmentMenu(QFrame, QRecruitBehaviour): def init_ui(self): main_layout = QVBoxLayout() - units = { - CAP: db.find_unittype(CAP, self.game_model.game.player_name), - CAS: db.find_unittype(CAS, self.game_model.game.player_name), - } + tasks = [CAP, CAS] scroll_content = QWidget() task_box_layout = QGridLayout() row = 0 - for task_type in units.keys(): - units_column = list(set(units[task_type])) - if len(units_column) == 0: + unit_types: Set[UnitType] = set() + for task in tasks: + units = db.find_unittype(task, self.game_model.game.player_name) + if not units: continue - units_column.sort(key=lambda x: db.PRICES[x]) - for unit_type in units_column: - if self.cp.is_carrier and not unit_type in db.CARRIER_CAPABLE: + for unit in units: + if self.cp.is_carrier and unit not in db.CARRIER_CAPABLE: continue - if self.cp.is_lha and not unit_type in db.LHA_CAPABLE: + if self.cp.is_lha and unit not in db.LHA_CAPABLE: continue - row = self.add_purchase_row(unit_type, task_box_layout, row) + unit_types.add(unit) + + sorted_units = sorted(unit_types, key=lambda u: db.unit_type_name_2(u)) + for unit_type in sorted_units: + row = self.add_purchase_row(unit_type, task_box_layout, row) stretch = QVBoxLayout() stretch.addStretch() task_box_layout.addLayout(stretch, row, 0) From 064890c0a287a9850fbbc293ce2715a48c3d99c8 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Sun, 4 Oct 2020 13:46:24 -0700 Subject: [PATCH 27/43] Mark the F-16 as SEAD capable. Latest OB update has HARMs for the F-16. --- gen/flights/ai_flight_planner_db.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gen/flights/ai_flight_planner_db.py b/gen/flights/ai_flight_planner_db.py index 715a7f66..2baadf7d 100644 --- a/gen/flights/ai_flight_planner_db.py +++ b/gen/flights/ai_flight_planner_db.py @@ -326,7 +326,7 @@ SEAD_CAPABLE = [ F_4E, FA_18C_hornet, F_15E, - # F_16C_50, Not yet + F_16C_50, AV8BNA, JF_17, From ed92e9afb9ea7abc39a34c5883957ec778336681 Mon Sep 17 00:00:00 2001 From: David Pierron Date: Sun, 18 Oct 2020 18:23:31 +0200 Subject: [PATCH 28/43] changed the system to make use of JSON files --- .gitignore | 2 - game/game.py | 5 +- game/operation/operation.py | 8 +- game/settings.py | 5 +- gen/armor.py | 5 +- plugin/__init__.py | 12 +- plugin/base_plugin.py | 48 - plugin/jtacautolase_plugin.py | 95 - plugin/liberation_plugin.py | 23 - plugin/luaplugin.py | 199 + plugin/manager.py | 43 + plugin/veaf_plugin.py | 90 - qt_ui/windows/settings/QSettingsWindow.py | 100 +- resources/plugins/{doc => _doc}/0.png | Bin resources/plugins/{doc => _doc}/1.png | Bin resources/plugins/{doc => _doc}/2.png | Bin resources/plugins/_doc/plugins_readme.md | 84 + resources/plugins/base/plugin.json | 22 + resources/plugins/custom/__plugins.lst.sample | 29 - resources/plugins/doc/plugins_readme.md | 58 - .../jtacautolase/jtacautolase-config.lua | 38 + .../plugins/jtacautolase/mist_4_3_74.lua | 6822 +++++++++++++++++ resources/plugins/jtacautolase/plugin.json | 28 + resources/plugins/plugins.json | 5 + resources/plugins/veaf | 1 - 25 files changed, 7305 insertions(+), 417 deletions(-) delete mode 100644 plugin/base_plugin.py delete mode 100644 plugin/jtacautolase_plugin.py delete mode 100644 plugin/liberation_plugin.py create mode 100644 plugin/luaplugin.py create mode 100644 plugin/manager.py delete mode 100644 plugin/veaf_plugin.py rename resources/plugins/{doc => _doc}/0.png (100%) rename resources/plugins/{doc => _doc}/1.png (100%) rename resources/plugins/{doc => _doc}/2.png (100%) create mode 100644 resources/plugins/_doc/plugins_readme.md create mode 100644 resources/plugins/base/plugin.json delete mode 100644 resources/plugins/custom/__plugins.lst.sample delete mode 100644 resources/plugins/doc/plugins_readme.md create mode 100644 resources/plugins/jtacautolase/jtacautolase-config.lua create mode 100644 resources/plugins/jtacautolase/mist_4_3_74.lua create mode 100644 resources/plugins/jtacautolase/plugin.json create mode 100644 resources/plugins/plugins.json delete mode 160000 resources/plugins/veaf diff --git a/.gitignore b/.gitignore index ec279d3d..26831339 100644 --- a/.gitignore +++ b/.gitignore @@ -21,5 +21,3 @@ logs/ qt_ui/logs/liberation.log *.psd -resources/plugins/custom/__plugins.lst -resources/plugins/custom/*.lua diff --git a/game/game.py b/game/game.py index e38b140f..00ca8b5f 100644 --- a/game/game.py +++ b/game/game.py @@ -28,7 +28,7 @@ from .event.event import Event, UnitsDeliveryEvent from .event.frontlineattack import FrontlineAttackEvent from .infos.information import Information from .settings import Settings -from plugin import INSTALLED_PLUGINS +from plugin import LuaPluginManager COMMISION_UNIT_VARIETY = 4 COMMISION_LIMITS_SCALE = 1.5 @@ -220,8 +220,7 @@ class Game: ObjectiveDistanceCache.set_theater(self.theater) # set the settings in all plugins - for pluginName in INSTALLED_PLUGINS: - plugin = INSTALLED_PLUGINS[pluginName] + for plugin in LuaPluginManager().getPlugins(): plugin.setSettings(self.settings) def pass_turn(self, no_action=False): diff --git a/game/operation/operation.py b/game/operation/operation.py index e42d7b14..c64d0992 100644 --- a/game/operation/operation.py +++ b/game/operation/operation.py @@ -31,7 +31,7 @@ from gen.triggergen import TRIGGER_RADIUS_MEDIUM, TriggersGenerator from theater import ControlPoint from .. import db from ..debriefing import Debriefing -from plugin import BasePlugin, INSTALLED_PLUGINS +from plugin import LuaPluginManager class Operation: attackers_starting_position = None # type: db.StartingPosition @@ -146,10 +146,7 @@ class Operation: if not scriptFileMnemonic in self.listOfPluginsScripts: self.listOfPluginsScripts.append(scriptFileMnemonic) - if pluginName == None: - pluginName = "custom" plugin_path = Path("./resources/plugins",pluginName) - logging.debug(f"plugin_path = {plugin_path}") if scriptFile != None: scriptFile_path = Path(plugin_path, scriptFile) @@ -469,8 +466,7 @@ dcsLiberation.TargetPoints = { # Inject Plugins Lua Scripts and data self.listOfPluginsScripts = [] - for pluginName in INSTALLED_PLUGINS: - plugin = INSTALLED_PLUGINS[pluginName] + for plugin in LuaPluginManager().getPlugins(): plugin.injectScripts(self) plugin.injectConfiguration(self) diff --git a/game/settings.py b/game/settings.py index bb7754d4..7b398330 100644 --- a/game/settings.py +++ b/game/settings.py @@ -1,4 +1,4 @@ -from plugin import INSTALLED_PLUGINS +from plugin import LuaPluginManager class Settings: @@ -41,8 +41,7 @@ class Settings: # LUA Plugins system self.plugins = {} - for pluginName in INSTALLED_PLUGINS: - plugin = INSTALLED_PLUGINS[pluginName] + for plugin in LuaPluginManager().getPlugins(): plugin.setSettings(self) diff --git a/gen/armor.py b/gen/armor.py index 21ae5a25..2873002a 100644 --- a/gen/armor.py +++ b/gen/armor.py @@ -34,7 +34,7 @@ from gen.ground_forces.ai_ground_planner import ( from .callsigns import callsign_for_support_unit from .conflictgen import Conflict from .ground_forces.combat_stance import CombatStance -from plugin import INSTALLED_PLUGINS +from plugin import LuaPluginManager SPREAD_DISTANCE_FACTOR = 0.1, 0.3 SPREAD_DISTANCE_SIZE_FACTOR = 0.1 @@ -140,7 +140,8 @@ class GroundConflictGenerator: self.plan_action_for_groups(self.enemy_stance, enemy_groups, player_groups, self.conflict.heading - 90, self.conflict.to_cp, self.conflict.from_cp) # Add JTAC - useJTAC = INSTALLED_PLUGINS and INSTALLED_PLUGINS["JtacAutolasePlugin"] and INSTALLED_PLUGINS["JtacAutolasePlugin"].isEnabled() + jtacPlugin = LuaPluginManager().getPlugin("jtacautolase") + useJTAC = jtacPlugin and jtacPlugin.isEnabled() if "has_jtac" in self.game.player_faction and self.game.player_faction["has_jtac"] and useJTAC: n = "JTAC" + str(self.conflict.from_cp.id) + str(self.conflict.to_cp.id) code = 1688 - len(self.jtacs) diff --git a/plugin/__init__.py b/plugin/__init__.py index 2bbf459c..37f3c6d4 100644 --- a/plugin/__init__.py +++ b/plugin/__init__.py @@ -1,10 +1,2 @@ -from .base_plugin import BasePlugin -from .veaf_plugin import VeafPlugin -from .jtacautolase_plugin import JtacAutolasePlugin -from .liberation_plugin import LiberationPlugin - -INSTALLED_PLUGINS={ - "VeafPlugin": VeafPlugin(), - "JtacAutolasePlugin": JtacAutolasePlugin(), - "LiberationPlugin": LiberationPlugin(), -} +from .luaplugin import LuaPlugin +from .manager import LuaPluginManager \ No newline at end of file diff --git a/plugin/base_plugin.py b/plugin/base_plugin.py deleted file mode 100644 index 4eb6e744..00000000 --- a/plugin/base_plugin.py +++ /dev/null @@ -1,48 +0,0 @@ -from PySide2.QtCore import QSize, Qt, QItemSelectionModel, QPoint -from PySide2.QtWidgets import QLabel, QDialog, QGridLayout, QListView, QStackedLayout, QComboBox, QWidget, \ - QAbstractItemView, QPushButton, QGroupBox, QCheckBox, QVBoxLayout, QSpinBox - -class BasePlugin(): - nameInUI:str = "Base plugin" - nameInSettings:str = "plugin.base" - enabledDefaultValue:bool = False - - def __init__(self): - self.uiWidget: QCheckBox = None - self.enabled = self.enabledDefaultValue - self.settings = None - - def setupUI(self, settingsWindow, row:int): - self.settings = settingsWindow.game.settings - - self.uiWidget = QCheckBox() - self.uiWidget.setChecked(self.isEnabled()) - self.uiWidget.toggled.connect(lambda: self.applySetting(settingsWindow)) - - settingsWindow.pluginsGroupLayout.addWidget(QLabel(self.nameInUI), row, 0) - settingsWindow.pluginsGroupLayout.addWidget(self.uiWidget, row, 1, Qt.AlignRight) - - def setSettings(self, settings): - self.settings = settings - if not self.nameInSettings in self.settings.plugins: - self.settings.plugins[self.nameInSettings] = self.enabledDefaultValue - - def applySetting(self, settingsWindow): - self.settings.plugins[self.nameInSettings] = self.uiWidget.isChecked() - self.enabled = self.settings.plugins[self.nameInSettings] - - def injectScripts(self, operation): - self.settings = operation.game.settings - return self.isEnabled() - - def injectConfiguration(self, operation): - self.settings = operation.game.settings - return self.isEnabled() - - def isEnabled(self) -> bool: - if not self.settings: - return False - - self.setSettings(self.settings) # create the necessary settings keys if needed - - return self.settings != None and self.settings.plugins[self.nameInSettings] diff --git a/plugin/jtacautolase_plugin.py b/plugin/jtacautolase_plugin.py deleted file mode 100644 index 8b0c316c..00000000 --- a/plugin/jtacautolase_plugin.py +++ /dev/null @@ -1,95 +0,0 @@ -from dcs.triggers import TriggerStart -from PySide2.QtCore import QSize, Qt, QItemSelectionModel, QPoint -from PySide2.QtWidgets import QLabel, QDialog, QGridLayout, QListView, QStackedLayout, QComboBox, QWidget, \ - QAbstractItemView, QPushButton, QGroupBox, QCheckBox, QVBoxLayout, QSpinBox -from .base_plugin import BasePlugin - -class JtacAutolasePlugin(BasePlugin): - nameInUI:str = "JTAC Autolase" - nameInSettings:str = "plugin.jtacAutolase" - enabledDefaultValue:bool = True - - #Allow spawn option - nameInUI_useSmoke:str = "JTACs use smoke" - nameInSettings_useSmoke:str = "plugin.jtacAutolase.useSmoke" - enabledDefaultValue_useSmoke:bool = True - - def setupUI(self, settingsWindow, row:int): - # call the base method to add the plugin selection checkbox - super().setupUI(settingsWindow, row) - - if settingsWindow.pluginsOptionsPageLayout: - self.optionsGroup = QGroupBox(self.nameInUI) - optionsGroupLayout = QGridLayout(); - optionsGroupLayout.setAlignment(Qt.AlignTop) - self.optionsGroup.setLayout(optionsGroupLayout) - settingsWindow.pluginsOptionsPageLayout.addWidget(self.optionsGroup) - - # JTAC use smoke - if not self.nameInSettings_useSmoke in self.settings.plugins: - self.settings.plugins[self.nameInSettings_useSmoke] = True - - self.uiWidget_useSmoke = QCheckBox() - self.uiWidget_useSmoke.setChecked(self.settings.plugins[self.nameInSettings_useSmoke]) - self.uiWidget_useSmoke.toggled.connect(lambda: self.applySetting(settingsWindow)) - - optionsGroupLayout.addWidget(QLabel(self.nameInUI_useSmoke), 0, 0) - optionsGroupLayout.addWidget(self.uiWidget_useSmoke, 0, 1, Qt.AlignRight) - - # disable or enable the UI in the plugins special page - self.enableOptionsGroup() - - def enableOptionsGroup(self): - pluginEnabled = self.uiWidget.isChecked() - self.optionsGroup.setEnabled(pluginEnabled) - - def setSettings(self, settings): - # call the base method - super().setSettings(settings) - if not self.nameInSettings_useSmoke in self.settings.plugins: - self.settings.plugins[self.nameInSettings_useSmoke] = self.enabledDefaultValue_useSmoke - - def applySetting(self, settingsWindow): - # call the base method to apply the plugin selection checkbox value - super().applySetting(settingsWindow) - - # save the "use smoke" option - self.settings.plugins[self.nameInSettings_useSmoke] = self.uiWidget_useSmoke.isChecked() - - # disable or enable the UI in the plugins special page - self.enableOptionsGroup() - - def injectScripts(self, operation): - if super().injectScripts(operation): - operation.injectPluginScript("jtacautolase", "JTACAutoLase.lua", "jtacautolase") - - def injectConfiguration(self, operation): - if super().injectConfiguration(operation): - - # add a configuration for JTACAutoLase and start lasing for all JTACs - smoke = "local smoke = false" - if self.isUseSmoke(): - smoke = "local smoke = true" - - lua = smoke + """ - - -- setting and starting JTACs - env.info("DCSLiberation|: setting and starting JTACs") - - for _, jtac in pairs(dcsLiberation.JTACs) do - if dcsLiberation.JTACAutoLase then - dcsLiberation.JTACAutoLase(jtac.dcsUnit, jtac.code, smoke, 'vehicle') - end - end - """ - - operation.injectLuaTrigger(lua, "Setting and starting JTACs") - - def isUseSmoke(self) -> bool: - if not self.settings: - return False - - self.setSettings(self.settings) # create the necessary settings keys if needed - - return self.settings.plugins[self.nameInSettings_useSmoke] - diff --git a/plugin/liberation_plugin.py b/plugin/liberation_plugin.py deleted file mode 100644 index ef97be3a..00000000 --- a/plugin/liberation_plugin.py +++ /dev/null @@ -1,23 +0,0 @@ -from .base_plugin import BasePlugin - -class LiberationPlugin(BasePlugin): - nameInUI:str = "Liberation script" - nameInSettings:str = "plugin.liberation" - enabledDefaultValue:bool = True - - def setupUI(self, settingsWindow, row:int): - # Don't setup any UI, this plugin is mandatory - pass - - def injectScripts(self, operation): - if super().injectScripts(operation): - operation.injectPluginScript("base", "mist_4_3_74.lua", "mist") - operation.injectPluginScript("base", "json.lua", "json") - operation.injectPluginScript("base", "dcs_liberation.lua", "liberation") - - def injectConfiguration(self, operation): - if super().injectConfiguration(operation): - pass - - def isEnabled(self) -> bool: - return True # mandatory plugin diff --git a/plugin/luaplugin.py b/plugin/luaplugin.py new file mode 100644 index 00000000..8f92ad76 --- /dev/null +++ b/plugin/luaplugin.py @@ -0,0 +1,199 @@ +from typing import List +from pathlib import Path +from PySide2.QtCore import QSize, Qt, QItemSelectionModel, QPoint +from PySide2.QtWidgets import QLabel, QDialog, QGridLayout, QListView, QStackedLayout, QComboBox, QWidget, \ + QAbstractItemView, QPushButton, QGroupBox, QCheckBox, QVBoxLayout, QSpinBox +import json + +class LuaPluginWorkOrder(): + + def __init__(self, parent, filename:str, mnemonic:str, disable:bool): + self.filename = filename + self.mnemonic = mnemonic + self.disable = disable + self.parent = parent + + def work(self, mnemonic:str, operation): + if self.disable: + operation.bypassPluginScript(self.parent.mnemonic, self.mnemonic) + else: + operation.injectPluginScript(self.parent.mnemonic, self.filename, self.mnemonic) + +class LuaPluginSpecificOption(): + + def __init__(self, parent, mnemonic:str, nameInUI:str, defaultValue:bool): + self.mnemonic = mnemonic + self.nameInUI = nameInUI + self.defaultValue = defaultValue + self.parent = parent + +class LuaPlugin(): + NAME_IN_SETTINGS_BASE:str = "plugins." + + def __init__(self, jsonFilename:str): + self.mnemonic:str = None + self.skipUI:bool = False + self.nameInUI:str = None + self.nameInSettings:str = None + self.defaultValue:bool = False + self.specificOptions = [] + self.scriptsWorkOrders: List[LuaPluginWorkOrder] = None + self.configurationWorkOrders: List[LuaPluginWorkOrder] = None + self.initFromJson(jsonFilename) + self.enabled = self.defaultValue + self.settings = None + + def initFromJson(self, jsonFilename:str): + jsonFile:Path = Path(jsonFilename) + if jsonFile.exists(): + jsonData = json.loads(jsonFile.read_text()) + self.mnemonic = jsonData.get("mnemonic") + self.skipUI = jsonData.get("skipUI", False) + self.nameInUI = jsonData.get("nameInUI") + self.nameInSettings = LuaPlugin.NAME_IN_SETTINGS_BASE + self.mnemonic + self.defaultValue = jsonData.get("defaultValue", False) + self.specificOptions = [] + for jsonSpecificOption in jsonData.get("specificOptions"): + mnemonic = jsonSpecificOption.get("mnemonic") + nameInUI = jsonSpecificOption.get("nameInUI", mnemonic) + defaultValue = jsonSpecificOption.get("defaultValue") + self.specificOptions.append(LuaPluginSpecificOption(self, mnemonic, nameInUI, defaultValue)) + self.scriptsWorkOrders = [] + for jsonWorkOrder in jsonData.get("scriptsWorkOrders"): + file = jsonWorkOrder.get("file") + mnemonic = jsonWorkOrder.get("mnemonic") + disable = jsonWorkOrder.get("disable", False) + self.scriptsWorkOrders.append(LuaPluginWorkOrder(self, file, mnemonic, disable)) + self.configurationWorkOrders = [] + for jsonWorkOrder in jsonData.get("configurationWorkOrders"): + file = jsonWorkOrder.get("file") + mnemonic = jsonWorkOrder.get("mnemonic") + disable = jsonWorkOrder.get("disable", False) + self.configurationWorkOrders.append(LuaPluginWorkOrder(self, file, mnemonic, disable)) + + def setupUI(self, settingsWindow, row:int): + # set the game settings + self.settings = settingsWindow.game.settings + + if not self.skipUI: + # create the plugin choice checkbox interface + self.uiWidget: QCheckBox = QCheckBox() + self.uiWidget.setChecked(self.isEnabled()) + self.uiWidget.toggled.connect(lambda: self.applySetting(settingsWindow)) + + settingsWindow.pluginsGroupLayout.addWidget(QLabel(self.nameInUI), row, 0) + settingsWindow.pluginsGroupLayout.addWidget(self.uiWidget, row, 1, Qt.AlignRight) + + # if needed, create the plugin options special page + if settingsWindow.pluginsOptionsPageLayout and self.specificOptions != None: + self.optionsGroup: QGroupBox = QGroupBox(self.nameInUI) + optionsGroupLayout = QGridLayout(); + optionsGroupLayout.setAlignment(Qt.AlignTop) + self.optionsGroup.setLayout(optionsGroupLayout) + settingsWindow.pluginsOptionsPageLayout.addWidget(self.optionsGroup) + + # browse each option in the specific options list + row = 0 + for specificOption in self.specificOptions: + nameInSettings = self.nameInSettings + specificOption.mnemonic + if not nameInSettings in self.settings.plugins: + self.settings.plugins[nameInSettings] = specificOption.defaultValue + + specificOption.uiWidget = QCheckBox() + specificOption.uiWidget.setChecked(self.settings.plugins[nameInSettings]) + #specificOption.uiWidget.setEnabled(False) + specificOption.uiWidget.toggled.connect(lambda: self.applySetting(settingsWindow)) + + optionsGroupLayout.addWidget(QLabel(specificOption.nameInUI), row, 0) + optionsGroupLayout.addWidget(specificOption.uiWidget, row, 1, Qt.AlignRight) + + row += 1 + + # disable or enable the UI in the plugins special page + self.enableOptionsGroup() + + def enableOptionsGroup(self): + if self.optionsGroup: + self.optionsGroup.setEnabled(self.isEnabled()) + + def setSettings(self, settings): + self.settings = settings + + # ensure the setting exist + if not self.nameInSettings in self.settings.plugins: + self.settings.plugins[self.nameInSettings] = self.defaultValue + + # do the same for each option in the specific options list + for specificOption in self.specificOptions: + nameInSettings = self.nameInSettings + "." + specificOption.mnemonic + if not nameInSettings in self.settings.plugins: + self.settings.plugins[nameInSettings] = specificOption.defaultValue + + def applySetting(self, settingsWindow): + # apply the main setting + self.settings.plugins[self.nameInSettings] = self.uiWidget.isChecked() + self.enabled = self.settings.plugins[self.nameInSettings] + + # do the same for each option in the specific options list + for specificOption in self.specificOptions: + nameInSettings = self.nameInSettings + specificOption.mnemonic + self.settings.plugins[nameInSettings] = specificOption.uiWidget.isChecked() + + # disable or enable the UI in the plugins special page + self.enableOptionsGroup() + + def injectScripts(self, operation): + # set the game settings + self.settings = operation.game.settings + + # execute the work order + if self.scriptsWorkOrders != None: + for workOrder in self.scriptsWorkOrders: + workOrder.work(self.mnemonic, operation) + + # serves for subclasses + return self.isEnabled() + + def injectConfiguration(self, operation): + # set the game settings + self.settings = operation.game.settings + + # inject the plugin options + if len(self.specificOptions) > 0: + defineAllOptions = "" + for specificOption in self.specificOptions: + nameInSettings = self.nameInSettings + specificOption.mnemonic + value = "true" if self.settings.plugins[nameInSettings] else "false" + defineAllOptions += f" dcsLiberation.plugins.{self.mnemonic}.{specificOption.mnemonic} = {value} \n" + + + lua = f"-- {self.mnemonic} plugin configuration.\n" + lua += "\n" + lua += "if dcsLiberation then\n" + lua += " if not dcsLiberation.plugins then \n" + lua += " dcsLiberation.plugins = {}\n" + lua += " end\n" + lua += f" dcsLiberation.plugins.{self.mnemonic} = {{}}\n" + lua += defineAllOptions + lua += "end" + + operation.injectLuaTrigger(lua, f"{self.mnemonic} plugin configuration") + + # execute the work order + if self.configurationWorkOrders != None: + for workOrder in self.configurationWorkOrders: + workOrder.work(self.mnemonic, operation) + + # serves for subclasses + return self.isEnabled() + + def isEnabled(self) -> bool: + if not self.settings: + return False + + self.setSettings(self.settings) # create the necessary settings keys if needed + + return self.settings != None and self.settings.plugins[self.nameInSettings] + + def hasUI(self) -> bool: + return not self.skipUI \ No newline at end of file diff --git a/plugin/manager.py b/plugin/manager.py new file mode 100644 index 00000000..d7625821 --- /dev/null +++ b/plugin/manager.py @@ -0,0 +1,43 @@ +from .luaplugin import LuaPlugin +from typing import List +import glob +from pathlib import Path +import json +import logging + + +class LuaPluginManager(): + PLUGINS_RESOURCE_PATH = Path("resources/plugins") + PLUGINS_LIST_FILENAME = "plugins.json" + PLUGINS_JSON_FILENAME = "plugin.json" + + __plugins = None + def __init__(self): + if not LuaPluginManager.__plugins: + LuaPluginManager.__plugins= [] + jsonFile:Path = Path(LuaPluginManager.PLUGINS_RESOURCE_PATH, LuaPluginManager.PLUGINS_LIST_FILENAME) + if jsonFile.exists(): + logging.info(f"Reading plugins list from {jsonFile}") + + jsonData = json.loads(jsonFile.read_text()) + for plugin in jsonData: + jsonPluginFolder = Path(LuaPluginManager.PLUGINS_RESOURCE_PATH, plugin) + jsonPluginFile = Path(jsonPluginFolder, LuaPluginManager.PLUGINS_JSON_FILENAME) + if jsonPluginFile.exists(): + logging.info(f"Reading plugin {plugin} from {jsonPluginFile}") + plugin = LuaPlugin(jsonPluginFile) + LuaPluginManager.__plugins.append(plugin) + else: + logging.error(f"Missing configuration file {jsonPluginFile} for plugin {plugin}") + else: + logging.error(f"Missing plugins list file {jsonFile}") + + def getPlugins(self): + return LuaPluginManager.__plugins + + def getPlugin(self, pluginName): + for plugin in LuaPluginManager.__plugins: + if plugin.mnemonic == pluginName: + return plugin + + return None \ No newline at end of file diff --git a/plugin/veaf_plugin.py b/plugin/veaf_plugin.py deleted file mode 100644 index 963ca4f8..00000000 --- a/plugin/veaf_plugin.py +++ /dev/null @@ -1,90 +0,0 @@ -from PySide2.QtCore import QSize, Qt, QItemSelectionModel, QPoint -from PySide2.QtWidgets import QLabel, QDialog, QGridLayout, QListView, QStackedLayout, QComboBox, QWidget, \ - QAbstractItemView, QPushButton, QGroupBox, QCheckBox, QVBoxLayout, QSpinBox -from .base_plugin import BasePlugin - -class VeafPlugin(BasePlugin): - nameInUI:str = "VEAF framework" - nameInSettings:str = "plugin.veaf" - enabledDefaultValue:bool = False - - #Allow spawn option - nameInUI_allowSpawn:str = "Allow units spawn via markers and CTLD (not implemented yet)" - nameInSettings_allowSpawn:str = "plugin.veaf.allowSpawn" - - def setupUI(self, settingsWindow, row:int): - # call the base method to add the plugin selection checkbox - super().setupUI(settingsWindow, row) - - if settingsWindow.pluginsOptionsPageLayout: - self.optionsGroup = QGroupBox(self.nameInUI) - optionsGroupLayout = QGridLayout(); - optionsGroupLayout.setAlignment(Qt.AlignTop) - self.optionsGroup.setLayout(optionsGroupLayout) - settingsWindow.pluginsOptionsPageLayout.addWidget(self.optionsGroup) - - # allow spawn of objects - if not self.nameInSettings_allowSpawn in self.settings.plugins: - self.settings.plugins[self.nameInSettings_allowSpawn] = True - - self.uiWidget_allowSpawn = QCheckBox() - self.uiWidget_allowSpawn.setChecked(self.settings.plugins[self.nameInSettings_allowSpawn]) - self.uiWidget_allowSpawn.setEnabled(False) - self.uiWidget_allowSpawn.toggled.connect(lambda: self.applySetting(settingsWindow)) - - optionsGroupLayout.addWidget(QLabel(self.nameInUI_allowSpawn), 0, 0) - optionsGroupLayout.addWidget(self.uiWidget_allowSpawn, 0, 1, Qt.AlignRight) - - # disable or enable the UI in the plugins special page - self.enableOptionsGroup() - - def enableOptionsGroup(self): - pluginEnabled = self.uiWidget.isChecked() - self.optionsGroup.setEnabled(pluginEnabled) - - def applySetting(self, settingsWindow): - # call the base method to apply the plugin selection checkbox value - super().applySetting(settingsWindow) - - # save the "allow spawn" option - self.settings.plugins[self.nameInSettings_allowSpawn] = self.uiWidget_allowSpawn.isChecked() - - # disable or enable the UI in the plugins special page - self.enableOptionsGroup() - - def injectScripts(self, operation): - if super().injectScripts(operation): - # bypass JTACAutoLase - operation.bypassPluginScript("veaf", "jtacautolase") - - # inject the required scripts - operation.injectPluginScript("veaf", "src\\scripts\\mist.lua", "mist") - operation.injectPluginScript("veaf", "src\\scripts\\Moose.lua", "moose") - operation.injectPluginScript("veaf", "src\\scripts\\CTLD.lua", "ctld") - #operation.injectPluginScript("veaf", "src\\scripts\\NIOD.lua", "niod") - operation.injectPluginScript("veaf", "src\\scripts\\WeatherMark.lua", "weathermark") - operation.injectPluginScript("veaf", "src\\scripts\\veaf.lua", "veaf") - operation.injectPluginScript("veaf", "src\\scripts\\dcsUnits.lua", "dcsunits") - operation.injectPluginScript("veaf", "src\\scripts\\veafAssets.lua", "veafassets") - operation.injectPluginScript("veaf", "src\\scripts\\veafCarrierOperations.lua", "veafcarrieroperations") - operation.injectPluginScript("veaf", "src\\scripts\\veafCasMission.lua", "veafcasmission") - operation.injectPluginScript("veaf", "src\\scripts\\veafCombatMission.lua", "veafcombatmission") - operation.injectPluginScript("veaf", "src\\scripts\\veafCombatZone.lua", "veafcombatzone") - operation.injectPluginScript("veaf", "src\\scripts\\veafGrass.lua", "veafgrass") - operation.injectPluginScript("veaf", "src\\scripts\\veafInterpreter.lua", "veafinterpreter") - operation.injectPluginScript("veaf", "src\\scripts\\veafMarkers.lua", "veafmarkers") - operation.injectPluginScript("veaf", "src\\scripts\\veafMove.lua", "veafmove") - operation.injectPluginScript("veaf", "src\\scripts\\veafNamedPoints.lua", "veafnamedpoints") - operation.injectPluginScript("veaf", "src\\scripts\\veafRadio.lua", "veafradio") - operation.injectPluginScript("veaf", "src\\scripts\\veafRemote.lua", "veafremote") - operation.injectPluginScript("veaf", "src\\scripts\\veafSecurity.lua", "veafsecurity") - operation.injectPluginScript("veaf", "src\\scripts\\veafShortcuts.lua", "veafshortcuts") - operation.injectPluginScript("veaf", "src\\scripts\\veafSpawn.lua", "veafspawn") - operation.injectPluginScript("veaf", "src\\scripts\\veafTransportMission.lua", "veaftransportmission") - operation.injectPluginScript("veaf", "src\\scripts\\veafUnits.lua", "veafunits") - - - def injectConfiguration(self, operation): - if super().injectConfiguration(operation): - operation.injectPluginScript("veaf", "src\\config\\missionConfig.lua", "veaf-config") - diff --git a/qt_ui/windows/settings/QSettingsWindow.py b/qt_ui/windows/settings/QSettingsWindow.py index 88382ee0..c61372a6 100644 --- a/qt_ui/windows/settings/QSettingsWindow.py +++ b/qt_ui/windows/settings/QSettingsWindow.py @@ -11,7 +11,7 @@ from game.game import Game from game.infos.information import Information from qt_ui.windows.GameUpdateSignal import GameUpdateSignal from qt_ui.windows.finances.QFinancesMenu import QHorizontalSeparationLine -from plugin import BasePlugin, INSTALLED_PLUGINS +from plugin import LuaPluginManager class QSettingsWindow(QDialog): @@ -19,6 +19,8 @@ class QSettingsWindow(QDialog): super(QSettingsWindow, self).__init__() self.game = game + self.pluginsPage = None + self.pluginsOptionsPage = None self.setModal(True) self.setWindowTitle("Settings") @@ -37,53 +39,53 @@ class QSettingsWindow(QDialog): self.categoryModel = QStandardItemModel(self.categoryList) + self.categoryList.setIconSize(QSize(32, 32)) + + self.initDifficultyLayout() difficulty = QStandardItem("Difficulty") difficulty.setIcon(CONST.ICONS["Missile"]) difficulty.setEditable(False) difficulty.setSelectable(True) + self.categoryModel.appendRow(difficulty) + self.right_layout.addWidget(self.difficultyPage) + self.initGeneratorLayout() generator = QStandardItem("Mission Generator") generator.setIcon(CONST.ICONS["Generator"]) generator.setEditable(False) generator.setSelectable(True) + self.categoryModel.appendRow(generator) + self.right_layout.addWidget(self.generatorPage) + self.initCheatLayout() cheat = QStandardItem("Cheat Menu") cheat.setIcon(CONST.ICONS["Cheat"]) cheat.setEditable(False) cheat.setSelectable(True) - - plugins = QStandardItem("LUA Plugins") - plugins.setIcon(CONST.ICONS["Plugins"]) - plugins.setEditable(False) - plugins.setSelectable(True) - - pluginsOptions = QStandardItem("LUA Plugins Options") - pluginsOptions.setIcon(CONST.ICONS["PluginsOptions"]) - pluginsOptions.setEditable(False) - pluginsOptions.setSelectable(True) - - self.categoryList.setIconSize(QSize(32, 32)) - self.categoryModel.appendRow(difficulty) - self.categoryModel.appendRow(generator) self.categoryModel.appendRow(cheat) - self.categoryModel.appendRow(plugins) - self.categoryModel.appendRow(pluginsOptions) + self.right_layout.addWidget(self.cheatPage) + + self.initPluginsLayout() + if self.pluginsPage: + plugins = QStandardItem("LUA Plugins") + plugins.setIcon(CONST.ICONS["Plugins"]) + plugins.setEditable(False) + plugins.setSelectable(True) + self.categoryModel.appendRow(plugins) + self.right_layout.addWidget(self.pluginsPage) + if self.pluginsOptionsPage: + pluginsOptions = QStandardItem("LUA Plugins Options") + pluginsOptions.setIcon(CONST.ICONS["PluginsOptions"]) + pluginsOptions.setEditable(False) + pluginsOptions.setSelectable(True) + self.categoryModel.appendRow(pluginsOptions) + self.right_layout.addWidget(self.pluginsOptionsPage) self.categoryList.setSelectionBehavior(QAbstractItemView.SelectRows) self.categoryList.setModel(self.categoryModel) self.categoryList.selectionModel().setCurrentIndex(self.categoryList.indexAt(QPoint(1,1)), QItemSelectionModel.Select) self.categoryList.selectionModel().selectionChanged.connect(self.onSelectionChanged) - self.initDifficultyLayout() - self.initGeneratorLayout() - self.initCheatLayout() - self.initPluginsLayout() - - self.right_layout.addWidget(self.difficultyPage) - self.right_layout.addWidget(self.generatorPage) - self.right_layout.addWidget(self.cheatPage) - self.right_layout.addWidget(self.pluginsPage) - self.right_layout.addWidget(self.pluginsOptionsPage) self.layout.addWidget(self.categoryList, 0, 0, 1, 1) self.layout.addLayout(self.right_layout, 0, 1, 5, 1) @@ -281,28 +283,32 @@ class QSettingsWindow(QDialog): self.cheatLayout.addWidget(self.moneyCheatBox, 0, 0) def initPluginsLayout(self): - self.pluginsOptionsPage = QWidget() - self.pluginsOptionsPageLayout = QVBoxLayout() - self.pluginsOptionsPageLayout.setAlignment(Qt.AlignTop) - self.pluginsOptionsPage.setLayout(self.pluginsOptionsPageLayout) - - self.pluginsPage = QWidget() - self.pluginsPageLayout = QVBoxLayout() - self.pluginsPageLayout.setAlignment(Qt.AlignTop) - self.pluginsPage.setLayout(self.pluginsPageLayout) - - self.pluginsGroup = QGroupBox("Plugins") - self.pluginsGroupLayout = QGridLayout(); - self.pluginsGroupLayout.setAlignment(Qt.AlignTop) - self.pluginsGroup.setLayout(self.pluginsGroupLayout) - + uiPrepared = False row:int = 0 - for pluginName in INSTALLED_PLUGINS: - plugin = INSTALLED_PLUGINS[pluginName] - plugin.setupUI(self, row) - row = row + 1 + for plugin in LuaPluginManager().getPlugins(): + if plugin.hasUI(): + if not uiPrepared: + uiPrepared = True - self.pluginsPageLayout.addWidget(self.pluginsGroup) + self.pluginsOptionsPage = QWidget() + self.pluginsOptionsPageLayout = QVBoxLayout() + self.pluginsOptionsPageLayout.setAlignment(Qt.AlignTop) + self.pluginsOptionsPage.setLayout(self.pluginsOptionsPageLayout) + + self.pluginsPage = QWidget() + self.pluginsPageLayout = QVBoxLayout() + self.pluginsPageLayout.setAlignment(Qt.AlignTop) + self.pluginsPage.setLayout(self.pluginsPageLayout) + + self.pluginsGroup = QGroupBox("Plugins") + self.pluginsGroupLayout = QGridLayout(); + self.pluginsGroupLayout.setAlignment(Qt.AlignTop) + self.pluginsGroup.setLayout(self.pluginsGroupLayout) + + self.pluginsPageLayout.addWidget(self.pluginsGroup) + + plugin.setupUI(self, row) + row = row + 1 def cheatLambda(self, amount): return lambda: self.cheatMoney(amount) diff --git a/resources/plugins/doc/0.png b/resources/plugins/_doc/0.png similarity index 100% rename from resources/plugins/doc/0.png rename to resources/plugins/_doc/0.png diff --git a/resources/plugins/doc/1.png b/resources/plugins/_doc/1.png similarity index 100% rename from resources/plugins/doc/1.png rename to resources/plugins/_doc/1.png diff --git a/resources/plugins/doc/2.png b/resources/plugins/_doc/2.png similarity index 100% rename from resources/plugins/doc/2.png rename to resources/plugins/_doc/2.png diff --git a/resources/plugins/_doc/plugins_readme.md b/resources/plugins/_doc/plugins_readme.md new file mode 100644 index 00000000..7df75d91 --- /dev/null +++ b/resources/plugins/_doc/plugins_readme.md @@ -0,0 +1,84 @@ +# LUA Plugin system + +This plugin system was made for injecting LUA scripts in dcs-liberation missions. + +The resources for the plugins are stored in the `resources/plugins` folder ; each plugin has its own folder. + +## How does the system work ? + +The application first reads the `resources/plugins/plugins.json` file to get a list of plugins to load, in order. +Each entry in this list should correspond to a subfolder of the `resources/plugins` directory, where a `plugin.json` file exists. +This file is the description of the plugin. + +### plugin.json + +The *base* and *jtacautolase* plugins both are included in the standard dcs-liberation distribution. +You can check their respective `plugin.json` files to understand how they work. +Here's a quick rundown of the file's components : + +- `mnemonic` : the short, technical name of the plugin. It's the name of the folder, and the name of the plugin in the application's settings +- `skipUI` : if *true*, this plugin will not appear in the plugins selection user interface. Useful to force a plugin ON or OFF (see the *base* plugin) +- `nameInUI` : the title of the plugin as it will appear in the plugins selection user interface. +- `defaultValue` : the selection value of the plugin, when first installed ; if true, plugin is selected. +- `specificOptions` : a list of specific plugin options + - `nameInUI` : the title of the option as it will appear in the plugins specific options user interface. + - `mnemonic` : the short, technical name of the option. It's the name of the LUA variable passed to the configuration script, and the name of the option in the application's settings + - `defaultValue` : the selection value of the option, when first installed ; if true, option is selected. +- `scriptsWorkOrders` : a list of work orders that can be used to load or disable loading a specific LUA script + - `file` : the name of the LUA file in the plugin folder. + - `mnemonic` : the technical name of the LUA component. The filename may be more precise than needed (e.g. include a version number) ; this is used to load each file only once, and also to disable loading a file + - `disable` : if true, the script will be disabled instead of loaded +- `configurationWorkOrders` : a list of work orders that can be used to load a configuration LUA script (same description as above) + +## Standard plugins + +### The *base* plugin + +The *base* plugin contains the scripts that are going to be injected in every dcs-liberation missions. +It is mandatory. + +### The *JTACAutolase* plugin + +This plugin replaces the vanilla JTAC functionality in dcs-liberation. + +### The *VEAF framework* plugin + +When enabled, this plugin will inject and configure the VEAF Framework scripts in the mission. + +These scripts add a lot of runtime functionalities : + +- spawning of units and groups (and portable TACANs) +- air-to-ground missions +- air-to-air missions +- transport missions +- carrier operations (not Moose) +- tanker move +- weather and ATC +- shelling a zone, lighting it up +- managing assets (tankers, awacs, aircraft carriers) : getting info, state, respawning them if needed +- managing named points (position, info, ATC) +- managing a dynamic radio menu +- managing remote calls to the mission through NIOD (RPC) and SLMOD (LUA sockets) +- managing security (not allowing everyone to do every action) +- define groups templates + +You can find the *VEAF Framework* plugin [on GitHub](https://github.com/VEAF/dcs-liberation-veaf-framework/releases) +For more information, please visit the [VEAF Framework documentation site](https://veaf.github.io/VEAF-Mission-Creation-Tools/) (work in progress) + +## Custom plugins + +The easiest way to create a custom plugin is to copy an existing plugin, and modify the files. + +## New settings pages + +![New settings pages](0.png "New settings pages") + +Custom plugins can be enabled or disabled in the new *LUA Plugins* settings page. + +![LUA Plugins settings page](1.png "LUA Plugins settings page") + +For plugins which expose specific options (such as "use smoke" for the *JTACAutoLase* plugin), the *LUA Plugins Options* settings page lists these options. + +![LUA Plugins Options settings page](2.png "LUA Plugins settings page") + + diff --git a/resources/plugins/base/plugin.json b/resources/plugins/base/plugin.json new file mode 100644 index 00000000..2234980e --- /dev/null +++ b/resources/plugins/base/plugin.json @@ -0,0 +1,22 @@ +{ + "mnemonic": "base", + "skipUI": true, + "nameInUI": "", + "defaultValue": true, + "specificOptions": [], + "scriptsWorkOrders": [ + { + "file": "mist_4_3_74.lua", + "mnemonic": "mist" + }, + { + "file": "json.lua", + "mnemonic": "json" + }, + { + "file": "dcs_liberation.lua", + "mnemonic": "liberation" + } + ], + "configurationWorkOrders": [] +} \ No newline at end of file diff --git a/resources/plugins/custom/__plugins.lst.sample b/resources/plugins/custom/__plugins.lst.sample deleted file mode 100644 index 27cbf0c0..00000000 --- a/resources/plugins/custom/__plugins.lst.sample +++ /dev/null @@ -1,29 +0,0 @@ -# this is a list of lua scripts that will be injected in the mission, in the same order -mist.lua -Moose.lua -CTLD.lua -NIOD.lua -WeatherMark.lua -veaf.lua -dcsUnits.lua -# JTACAutoLase is an empty file, only there to disable loading the official script (already included in CTLD) -JTACAutoLase.lua -veafAssets.lua -veafCarrierOperations.lua -veafCarrierOperations2.lua -veafCasMission.lua -veafCombatMission.lua -veafCombatZone.lua -veafGrass.lua -veafInterpreter.lua -veafMarkers.lua -veafMove.lua -veafNamedPoints.lua -veafRadio.lua -veafRemote.lua -veafSecurity.lua -veafShortcuts.lua -veafSpawn.lua -veafTransportMission.lua -veafUnits.lua -missionConfig.lua diff --git a/resources/plugins/doc/plugins_readme.md b/resources/plugins/doc/plugins_readme.md deleted file mode 100644 index 41c90955..00000000 --- a/resources/plugins/doc/plugins_readme.md +++ /dev/null @@ -1,58 +0,0 @@ -# LUA Plugin system - -This plugin system was made for injecting LUA scripts in dcs-liberation missions. - -The resources for the plugins are stored in the `resources/plugins` folder ; each plugin has its own folder. - -## Standard plugins - -### The *base* plugin - -The *base* plugin contains the scripts that are going to be injected in every dcs-liberation missions. -It is mandatory. - -### The *JTACAutolase* plugin - -This plugin replaces the vanilla JTAC functionality in dcs-liberation. - -### The *VEAF framework* plugin - -When enabled, this plugin will inject and configure the VEAF Framework scripts in the mission. - -These scripts add a lot of runtime functionalities : - -- spawning of units and groups (and portable TACANs) -- air-to-ground missions -- air-to-air missions -- transport missions -- carrier operations (not Moose) -- tanker move -- weather and ATC -- shelling a zone, lighting it up -- managing assets (tankers, awacs, aircraft carriers) : getting info, state, respawning them if needed -- managing named points (position, info, ATC) -- managing a dynamic radio menu -- managing remote calls to the mission through NIOD (RPC) and SLMOD (LUA sockets) -- managing security (not allowing everyone to do every action) -- define groups templates - -For more information, please visit the [VEAF Framework documentation site](https://veaf.github.io/VEAF-Mission-Creation-Tools/) (work in progress) - -## Custom plugins - -Custom scripts can also be injected by dropping them in the `resources/plugins/custom` folder, and writing a `__plugins.lst` file listing them in order. -See the `__plugins.lst.sample` file for an example. - -## New settings pages - -![New settings pages](0.png "New settings pages") - -Custom plugins can be enabled or disabled in the new *LUA Plugins* settings page. - -![LUA Plugins settings page](1.png "LUA Plugins settings page") - -For plugins which expose specific options (such as "use smoke" for the *JTACAutoLase* plugin), the *LUA Plugins Options* settings page lists these options. - -![LUA Plugins Options settings page](2.png "LUA Plugins settings page") - - diff --git a/resources/plugins/jtacautolase/jtacautolase-config.lua b/resources/plugins/jtacautolase/jtacautolase-config.lua new file mode 100644 index 00000000..04d0c293 --- /dev/null +++ b/resources/plugins/jtacautolase/jtacautolase-config.lua @@ -0,0 +1,38 @@ +------------------------------------------------------------------------------------------------------------------------------------------------------------- +-- configuration file for the JTAC Autolase framework +-- +-- This configuration is tailored for a mission generated by DCS Liberation +-- see https://github.com/Khopa/dcs_liberation +------------------------------------------------------------------------------------------------------------------------------------------------------------- + +-- JTACAutolase plugin - configuration +env.info("DCSLiberation|JTACAutolase plugin - configuration") + +if dcsLiberation then + veaf.logTrace("dcsLiberation") + + -- specific options + local smoke = false + + -- retrieve specific options values + if dcsLiberation.plugins then + veaf.logTrace("dcsLiberation.plugins") + + if dcsLiberation.plugins.jtacautolase then + veaf.logTrace("dcsLiberation.plugins.jtacautolase") + veaf.logTrace(string.format("dcsLiberation.plugins.jtacautolase.smoke=%s",veaf.p(dcsLiberation.plugins.jtacautolase.smoke))) + + smoke = dcsLiberation.plugins.jtacautolase.smoke + end + end + + veaf.logTrace(string.format("smoke=%s",veaf.p(smoke))) + + -- actual configuration code + for _, jtac in pairs(dcsLiberation.JTACs) do + if dcsLiberation.JTACAutoLase then + dcsLiberation.JTACAutoLase(jtac.dcsUnit, jtac.code, smoke, 'vehicle') + end + end + +end \ No newline at end of file diff --git a/resources/plugins/jtacautolase/mist_4_3_74.lua b/resources/plugins/jtacautolase/mist_4_3_74.lua new file mode 100644 index 00000000..ffb822a4 --- /dev/null +++ b/resources/plugins/jtacautolase/mist_4_3_74.lua @@ -0,0 +1,6822 @@ +--[[-- +MIST Mission Scripting Tools. +## Description: +MIssion Scripting Tools (MIST) is a collection of Lua functions +and databases that is intended to be a supplement to the standard +Lua functions included in the simulator scripting engine. + +MIST functions and databases provide ready-made solutions to many common +scripting tasks and challenges, enabling easier scripting and saving +mission scripters time. The table mist.flagFuncs contains a set of +Lua functions (that are similar to Slmod functions) that do not +require detailed Lua knowledge to use. + +However, the majority of MIST does require knowledge of the Lua language, +and, if you are going to utilize these components of MIST, it is necessary +that you read the Simulator Scripting Engine guide on the official ED wiki. + +## Links: + +ED Forum Thread: + +##Github: + +Development + +Official Releases + +@script MIST +@author Speed +@author Grimes +@author lukrop +]] +mist = {} + +-- don't change these +mist.majorVersion = 4 +mist.minorVersion = 3 +mist.build = 74 + +-- forward declaration of log shorthand +local log + +do -- the main scope + local coroutines = {} + + local tempSpawnedUnits = {} -- birth events added here + local tempSpawnedGroups = {} + local tempSpawnGroupsCounter = 0 + + local mistAddedObjects = {} -- mist.dynAdd unit data added here + local mistAddedGroups = {} -- mist.dynAdd groupdata added here + local writeGroups = {} + local lastUpdateTime = 0 + + local updateAliveUnitsCounter = 0 + local updateTenthSecond = 0 + + local mistGpId = 7000 + local mistUnitId = 7000 + local mistDynAddIndex = {[' air '] = 0, [' hel '] = 0, [' gnd '] = 0, [' bld '] = 0, [' static '] = 0, [' shp '] = 0} + + local scheduledTasks = {} + local taskId = 0 + local idNum = 0 + + mist.nextGroupId = 1 + mist.nextUnitId = 1 + + local dbLog + + local function initDBs() -- mist.DBs scope + mist.DBs = {} + + mist.DBs.missionData = {} + if env.mission then + + mist.DBs.missionData.startTime = env.mission.start_time + mist.DBs.missionData.theatre = env.mission.theatre + mist.DBs.missionData.version = env.mission.version + mist.DBs.missionData.files = {} + if type(env.mission.resourceCounter) == 'table' then + for fIndex, fData in pairs (env.mission.resourceCounter) do + mist.DBs.missionData.files[#mist.DBs.missionData.files + 1] = mist.utils.deepCopy(fIndex) + end + end + -- if we add more coalition specific data then bullsye should be categorized by coaliton. For now its just the bullseye table + mist.DBs.missionData.bullseye = {red = {}, blue = {}} + mist.DBs.missionData.bullseye.red.x = env.mission.coalition.red.bullseye.x --should it be point.x? + mist.DBs.missionData.bullseye.red.y = env.mission.coalition.red.bullseye.y + mist.DBs.missionData.bullseye.blue.x = env.mission.coalition.blue.bullseye.x + mist.DBs.missionData.bullseye.blue.y = env.mission.coalition.blue.bullseye.y + end + + mist.DBs.zonesByName = {} + mist.DBs.zonesByNum = {} + + + if env.mission.triggers and env.mission.triggers.zones then + for zone_ind, zone_data in pairs(env.mission.triggers.zones) do + if type(zone_data) == 'table' then + local zone = mist.utils.deepCopy(zone_data) + zone.point = {} -- point is used by SSE + zone.point.x = zone_data.x + zone.point.y = 0 + zone.point.z = zone_data.y + + mist.DBs.zonesByName[zone_data.name] = zone + mist.DBs.zonesByNum[#mist.DBs.zonesByNum + 1] = mist.utils.deepCopy(zone) --[[deepcopy so that the zone in zones_by_name and the zone in + zones_by_num se are different objects.. don't want them linked.]] + end + end + end + + mist.DBs.navPoints = {} + mist.DBs.units = {} + --Build mist.db.units and mist.DBs.navPoints + for coa_name, coa_data in pairs(env.mission.coalition) do + + if (coa_name == 'red' or coa_name == 'blue') and type(coa_data) == 'table' then + mist.DBs.units[coa_name] = {} + + -- build nav points DB + mist.DBs.navPoints[coa_name] = {} + if coa_data.nav_points then --navpoints + --mist.debug.writeData (mist.utils.serialize,{'NavPoints',coa_data.nav_points}, 'NavPoints.txt') + for nav_ind, nav_data in pairs(coa_data.nav_points) do + + if type(nav_data) == 'table' then + mist.DBs.navPoints[coa_name][nav_ind] = mist.utils.deepCopy(nav_data) + + mist.DBs.navPoints[coa_name][nav_ind].name = nav_data.callsignStr -- name is a little bit more self-explanatory. + mist.DBs.navPoints[coa_name][nav_ind].point = {} -- point is used by SSE, support it. + mist.DBs.navPoints[coa_name][nav_ind].point.x = nav_data.x + mist.DBs.navPoints[coa_name][nav_ind].point.y = 0 + mist.DBs.navPoints[coa_name][nav_ind].point.z = nav_data.y + end + end + end + if coa_data.country then --there is a country table + for cntry_id, cntry_data in pairs(coa_data.country) do + + local countryName = string.lower(cntry_data.name) + mist.DBs.units[coa_name][countryName] = {} + mist.DBs.units[coa_name][countryName].countryId = cntry_data.id + + if type(cntry_data) == 'table' then --just making sure + + for obj_type_name, obj_type_data in pairs(cntry_data) do + + if obj_type_name == "helicopter" or obj_type_name == "ship" or obj_type_name == "plane" or obj_type_name == "vehicle" or obj_type_name == "static" then --should be an unncessary check + + local category = obj_type_name + + if ((type(obj_type_data) == 'table') and obj_type_data.group and (type(obj_type_data.group) == 'table') and (#obj_type_data.group > 0)) then --there's a group! + + mist.DBs.units[coa_name][countryName][category] = {} + + for group_num, group_data in pairs(obj_type_data.group) do + + if group_data and group_data.units and type(group_data.units) == 'table' then --making sure again- this is a valid group + + mist.DBs.units[coa_name][countryName][category][group_num] = {} + local groupName = group_data.name + if env.mission.version > 7 then + groupName = env.getValueDictByKey(groupName) + end + mist.DBs.units[coa_name][countryName][category][group_num].groupName = groupName + mist.DBs.units[coa_name][countryName][category][group_num].groupId = group_data.groupId + mist.DBs.units[coa_name][countryName][category][group_num].category = category + mist.DBs.units[coa_name][countryName][category][group_num].coalition = coa_name + mist.DBs.units[coa_name][countryName][category][group_num].country = countryName + mist.DBs.units[coa_name][countryName][category][group_num].countryId = cntry_data.id + mist.DBs.units[coa_name][countryName][category][group_num].startTime = group_data.start_time + mist.DBs.units[coa_name][countryName][category][group_num].task = group_data.task + mist.DBs.units[coa_name][countryName][category][group_num].hidden = group_data.hidden + + mist.DBs.units[coa_name][countryName][category][group_num].units = {} + + mist.DBs.units[coa_name][countryName][category][group_num].radioSet = group_data.radioSet + mist.DBs.units[coa_name][countryName][category][group_num].uncontrolled = group_data.uncontrolled + mist.DBs.units[coa_name][countryName][category][group_num].frequency = group_data.frequency + mist.DBs.units[coa_name][countryName][category][group_num].modulation = group_data.modulation + + for unit_num, unit_data in pairs(group_data.units) do + local units_tbl = mist.DBs.units[coa_name][countryName][category][group_num].units --pointer to the units table for this group + + units_tbl[unit_num] = {} + if env.mission.version > 7 then + units_tbl[unit_num].unitName = env.getValueDictByKey(unit_data.name) + else + units_tbl[unit_num].unitName = unit_data.name + end + units_tbl[unit_num].type = unit_data.type + units_tbl[unit_num].skill = unit_data.skill --will be nil for statics + units_tbl[unit_num].unitId = unit_data.unitId + units_tbl[unit_num].category = category + units_tbl[unit_num].coalition = coa_name + units_tbl[unit_num].country = countryName + units_tbl[unit_num].countryId = cntry_data.id + units_tbl[unit_num].heading = unit_data.heading + units_tbl[unit_num].playerCanDrive = unit_data.playerCanDrive + units_tbl[unit_num].alt = unit_data.alt + units_tbl[unit_num].alt_type = unit_data.alt_type + units_tbl[unit_num].speed = unit_data.speed + units_tbl[unit_num].livery_id = unit_data.livery_id + if unit_data.point then --ME currently does not work like this, but it might one day + units_tbl[unit_num].point = unit_data.point + else + units_tbl[unit_num].point = {} + units_tbl[unit_num].point.x = unit_data.x + units_tbl[unit_num].point.y = unit_data.y + end + units_tbl[unit_num].x = unit_data.x + units_tbl[unit_num].y = unit_data.y + + units_tbl[unit_num].callsign = unit_data.callsign + units_tbl[unit_num].onboard_num = unit_data.onboard_num + units_tbl[unit_num].hardpoint_racks = unit_data.hardpoint_racks + units_tbl[unit_num].psi = unit_data.psi + + + units_tbl[unit_num].groupName = groupName + units_tbl[unit_num].groupId = group_data.groupId + + if unit_data.AddPropAircraft then + units_tbl[unit_num].AddPropAircraft = unit_data.AddPropAircraft + end + + if category == 'static' then + units_tbl[unit_num].categoryStatic = unit_data.category + units_tbl[unit_num].shape_name = unit_data.shape_name + if unit_data.mass then + units_tbl[unit_num].mass = unit_data.mass + end + + if unit_data.canCargo then + units_tbl[unit_num].canCargo = unit_data.canCargo + end + end + + end --for unit_num, unit_data in pairs(group_data.units) do + end --if group_data and group_data.units then + end --for group_num, group_data in pairs(obj_type_data.group) do + end --if ((type(obj_type_data) == 'table') and obj_type_data.group and (type(obj_type_data.group) == 'table') and (#obj_type_data.group > 0)) then + end --if obj_type_name == "helicopter" or obj_type_name == "ship" or obj_type_name == "plane" or obj_type_name == "vehicle" or obj_type_name == "static" then + end --for obj_type_name, obj_type_data in pairs(cntry_data) do + end --if type(cntry_data) == 'table' then + end --for cntry_id, cntry_data in pairs(coa_data.country) do + end --if coa_data.country then --there is a country table + end --if coa_name == 'red' or coa_name == 'blue' and type(coa_data) == 'table' then + end --for coa_name, coa_data in pairs(mission.coalition) do + + mist.DBs.unitsByName = {} + mist.DBs.unitsById = {} + mist.DBs.unitsByCat = {} + + mist.DBs.unitsByCat.helicopter = {} -- adding default categories + mist.DBs.unitsByCat.plane = {} + mist.DBs.unitsByCat.ship = {} + mist.DBs.unitsByCat.static = {} + mist.DBs.unitsByCat.vehicle = {} + + mist.DBs.unitsByNum = {} + + mist.DBs.groupsByName = {} + mist.DBs.groupsById = {} + mist.DBs.humansByName = {} + mist.DBs.humansById = {} + + mist.DBs.dynGroupsAdded = {} -- will be filled by mist.dbUpdate from dynamically spawned groups + mist.DBs.activeHumans = {} + + mist.DBs.aliveUnits = {} -- will be filled in by the "updateAliveUnits" coroutine in mist.main. + + mist.DBs.removedAliveUnits = {} -- will be filled in by the "updateAliveUnits" coroutine in mist.main. + + mist.DBs.const = {} + + -- not accessible by SSE, must use static list :-/ + mist.DBs.const.callsigns = { + ['NATO'] = { + ['rules'] = { + ['groupLimit'] = 9, + }, + ['AWACS'] = { + ['Overlord'] = 1, + ['Magic'] = 2, + ['Wizard'] = 3, + ['Focus'] = 4, + ['Darkstar'] = 5, + }, + ['TANKER'] = { + ['Texaco'] = 1, + ['Arco'] = 2, + ['Shell'] = 3, + }, + ['JTAC'] = { + ['Axeman'] = 1, + ['Darknight'] = 2, + ['Warrior'] = 3, + ['Pointer'] = 4, + ['Eyeball'] = 5, + ['Moonbeam'] = 6, + ['Whiplash'] = 7, + ['Finger'] = 8, + ['Pinpoint'] = 9, + ['Ferret'] = 10, + ['Shaba'] = 11, + ['Playboy'] = 12, + ['Hammer'] = 13, + ['Jaguar'] = 14, + ['Deathstar'] = 15, + ['Anvil'] = 16, + ['Firefly'] = 17, + ['Mantis'] = 18, + ['Badger'] = 19, + }, + ['aircraft'] = { + ['Enfield'] = 1, + ['Springfield'] = 2, + ['Uzi'] = 3, + ['Colt'] = 4, + ['Dodge'] = 5, + ['Ford'] = 6, + ['Chevy'] = 7, + ['Pontiac'] = 8, + }, + + ['unique'] = { + ['A10'] = { + ['Hawg'] = 9, + ['Boar'] = 10, + ['Pig'] = 11, + ['Tusk'] = 12, + ['rules'] = { + ['canUseAircraft'] = true, + ['appliesTo'] = { + 'A-10C', + 'A-10A', + }, + }, + }, + }, + }, + } + mist.DBs.const.shapeNames = { + ["Landmine"] = "landmine", + ["FARP CP Blindage"] = "kp_ug", + ["Subsidiary structure C"] = "saray-c", + ["Barracks 2"] = "kazarma2", + ["Small house 2C"] = "dom2c", + ["Military staff"] = "aviashtab", + ["Tech hangar A"] = "ceh_ang_a", + ["Oil derrick"] = "neftevyshka", + ["Tech combine"] = "kombinat", + ["Garage B"] = "garage_b", + ["Airshow_Crowd"] = "Crowd1", + ["Hangar A"] = "angar_a", + ["Repair workshop"] = "tech", + ["Subsidiary structure D"] = "saray-d", + ["FARP Ammo Dump Coating"] = "SetkaKP", + ["Small house 1C area"] = "dom2c-all", + ["Tank 2"] = "airbase_tbilisi_tank_01", + ["Boiler-house A"] = "kotelnaya_a", + ["Workshop A"] = "tec_a", + ["Small werehouse 1"] = "s1", + ["Garage small B"] = "garagh-small-b", + ["Small werehouse 4"] = "s4", + ["Shop"] = "magazin", + ["Subsidiary structure B"] = "saray-b", + ["FARP Fuel Depot"] = "GSM Rus", + ["Coach cargo"] = "wagon-gruz", + ["Electric power box"] = "tr_budka", + ["Tank 3"] = "airbase_tbilisi_tank_02", + ["Red_Flag"] = "H-flag_R", + ["Container red 3"] = "konteiner_red3", + ["Garage A"] = "garage_a", + ["Hangar B"] = "angar_b", + ["Black_Tyre"] = "H-tyre_B", + ["Cafe"] = "stolovaya", + ["Restaurant 1"] = "restoran1", + ["Subsidiary structure A"] = "saray-a", + ["Container white"] = "konteiner_white", + ["Warehouse"] = "sklad", + ["Tank"] = "bak", + ["Railway crossing B"] = "pereezd_small", + ["Subsidiary structure F"] = "saray-f", + ["Farm A"] = "ferma_a", + ["Small werehouse 3"] = "s3", + ["Water tower A"] = "wodokachka_a", + ["Railway station"] = "r_vok_sd", + ["Coach a tank blue"] = "wagon-cisterna_blue", + ["Supermarket A"] = "uniwersam_a", + ["Coach a platform"] = "wagon-platforma", + ["Garage small A"] = "garagh-small-a", + ["TV tower"] = "tele_bash", + ["Comms tower M"] = "tele_bash_m", + ["Small house 1A"] = "domik1a", + ["Farm B"] = "ferma_b", + ["GeneratorF"] = "GeneratorF", + ["Cargo1"] = "ab-212_cargo", + ["Container red 2"] = "konteiner_red2", + ["Subsidiary structure E"] = "saray-e", + ["Coach a passenger"] = "wagon-pass", + ["Black_Tyre_WF"] = "H-tyre_B_WF", + ["Electric locomotive"] = "elektrowoz", + ["Shelter"] = "ukrytie", + ["Coach a tank yellow"] = "wagon-cisterna_yellow", + ["Railway crossing A"] = "pereezd_big", + [".Ammunition depot"] = "SkladC", + ["Small werehouse 2"] = "s2", + ["Windsock"] = "H-Windsock_RW", + ["Shelter B"] = "ukrytie_b", + ["Fuel tank"] = "toplivo-bak", + ["Locomotive"] = "teplowoz", + [".Command Center"] = "ComCenter", + ["Pump station"] = "nasos", + ["Black_Tyre_RF"] = "H-tyre_B_RF", + ["Coach cargo open"] = "wagon-gruz-otkr", + ["Subsidiary structure 3"] = "hozdomik3", + ["FARP Tent"] = "PalatkaB", + ["White_Tyre"] = "H-tyre_W", + ["Subsidiary structure G"] = "saray-g", + ["Container red 1"] = "konteiner_red1", + ["Small house 1B area"] = "domik1b-all", + ["Subsidiary structure 1"] = "hozdomik1", + ["Container brown"] = "konteiner_brown", + ["Small house 1B"] = "domik1b", + ["Subsidiary structure 2"] = "hozdomik2", + ["Chemical tank A"] = "him_bak_a", + ["WC"] = "WC", + ["Small house 1A area"] = "domik1a-all", + ["White_Flag"] = "H-Flag_W", + ["Airshow_Cone"] = "Comp_cone", + } + + + -- create mist.DBs.oldAliveUnits + -- do + -- local intermediate_alive_units = {} -- between 0 and 0.5 secs old + -- local function make_old_alive_units() -- called every 0.5 secs, makes the old_alive_units DB which is just a copy of alive_units that is 0.5 to 1 sec old + -- if intermediate_alive_units then + -- mist.DBs.oldAliveUnits = mist.utils.deepCopy(intermediate_alive_units) + -- end + -- intermediate_alive_units = mist.utils.deepCopy(mist.DBs.aliveUnits) + -- timer.scheduleFunction(make_old_alive_units, nil, timer.getTime() + 0.5) + -- end + + -- make_old_alive_units() + -- end + + --Build DBs + for coa_name, coa_data in pairs(mist.DBs.units) do + for cntry_name, cntry_data in pairs(coa_data) do + for category_name, category_data in pairs(cntry_data) do + if type(category_data) == 'table' then + for group_ind, group_data in pairs(category_data) do + if type(group_data) == 'table' and group_data.units and type(group_data.units) == 'table' and #group_data.units > 0 then -- OCD paradigm programming + mist.DBs.groupsByName[group_data.groupName] = mist.utils.deepCopy(group_data) + mist.DBs.groupsById[group_data.groupId] = mist.utils.deepCopy(group_data) + for unit_ind, unit_data in pairs(group_data.units) do + mist.DBs.unitsByName[unit_data.unitName] = mist.utils.deepCopy(unit_data) + mist.DBs.unitsById[unit_data.unitId] = mist.utils.deepCopy(unit_data) + + mist.DBs.unitsByCat[unit_data.category] = mist.DBs.unitsByCat[unit_data.category] or {} -- future-proofing against new categories... + table.insert(mist.DBs.unitsByCat[unit_data.category], mist.utils.deepCopy(unit_data)) + dbLog:info('inserting $1', unit_data.unitName) + table.insert(mist.DBs.unitsByNum, mist.utils.deepCopy(unit_data)) + + if unit_data.skill and (unit_data.skill == "Client" or unit_data.skill == "Player") then + mist.DBs.humansByName[unit_data.unitName] = mist.utils.deepCopy(unit_data) + mist.DBs.humansById[unit_data.unitId] = mist.utils.deepCopy(unit_data) + --if Unit.getByName(unit_data.unitName) then + -- mist.DBs.activeHumans[unit_data.unitName] = mist.utils.deepCopy(unit_data) + -- mist.DBs.activeHumans[unit_data.unitName].playerName = Unit.getByName(unit_data.unitName):getPlayerName() + --end + end + end + end + end + end + end + end + end + + --DynDBs + mist.DBs.MEunits = mist.utils.deepCopy(mist.DBs.units) + mist.DBs.MEunitsByName = mist.utils.deepCopy(mist.DBs.unitsByName) + mist.DBs.MEunitsById = mist.utils.deepCopy(mist.DBs.unitsById) + mist.DBs.MEunitsByCat = mist.utils.deepCopy(mist.DBs.unitsByCat) + mist.DBs.MEunitsByNum = mist.utils.deepCopy(mist.DBs.unitsByNum) + mist.DBs.MEgroupsByName = mist.utils.deepCopy(mist.DBs.groupsByName) + mist.DBs.MEgroupsById = mist.utils.deepCopy(mist.DBs.groupsById) + + mist.DBs.deadObjects = {} + + do + local mt = {} + + function mt.__newindex(t, key, val) + local original_key = key --only for duplicate runtime IDs. + local key_ind = 1 + while mist.DBs.deadObjects[key] do + dbLog:warn('duplicate runtime id of previously dead object key: $1', key) + key = tostring(original_key) .. ' #' .. tostring(key_ind) + key_ind = key_ind + 1 + end + + if mist.DBs.aliveUnits and mist.DBs.aliveUnits[val.object.id_] then + --dbLog:info('object found in alive_units') + val.objectData = mist.utils.deepCopy(mist.DBs.aliveUnits[val.object.id_]) + local pos = Object.getPosition(val.object) + if pos then + val.objectPos = pos.p + end + val.objectType = mist.DBs.aliveUnits[val.object.id_].category + + elseif mist.DBs.removedAliveUnits and mist.DBs.removedAliveUnits[val.object.id_] then -- it didn't exist in alive_units, check old_alive_units + --dbLog:info('object found in old_alive_units') + val.objectData = mist.utils.deepCopy(mist.DBs.removedAliveUnits[val.object.id_]) + local pos = Object.getPosition(val.object) + if pos then + val.objectPos = pos.p + end + val.objectType = mist.DBs.removedAliveUnits[val.object.id_].category + + else --attempt to determine if static object... + --dbLog:info('object not found in alive units or old alive units') + local pos = Object.getPosition(val.object) + if pos then + local static_found = false + for ind, static in pairs(mist.DBs.unitsByCat.static) do + if ((pos.p.x - static.point.x)^2 + (pos.p.z - static.point.y)^2)^0.5 < 0.1 then --really, it should be zero... + dbLog:info('correlated dead static object to position') + val.objectData = static + val.objectPos = pos.p + val.objectType = 'static' + static_found = true + break + end + end + if not static_found then + val.objectPos = pos.p + val.objectType = 'building' + end + else + val.objectType = 'unknown' + end + end + rawset(t, key, val) + end + + setmetatable(mist.DBs.deadObjects, mt) + end + + do -- mist unitID funcs + for id, idData in pairs(mist.DBs.unitsById) do + if idData.unitId > mist.nextUnitId then + mist.nextUnitId = mist.utils.deepCopy(idData.unitId) + end + if idData.groupId > mist.nextGroupId then + mist.nextGroupId = mist.utils.deepCopy(idData.groupId) + end + end + end + + + end + + local function updateAliveUnits() -- coroutine function + local lalive_units = mist.DBs.aliveUnits -- local references for faster execution + local lunits = mist.DBs.unitsByNum + local ldeepcopy = mist.utils.deepCopy + local lUnit = Unit + local lremovedAliveUnits = mist.DBs.removedAliveUnits + local updatedUnits = {} + + if #lunits > 0 then + local units_per_run = math.ceil(#lunits/20) + if units_per_run < 5 then + units_per_run = 5 + end + + for i = 1, #lunits do + if lunits[i].category ~= 'static' then -- can't get statics with Unit.getByName :( + local unit = lUnit.getByName(lunits[i].unitName) + if unit then + --dbLog:info("unit named $1 alive!", lunits[i].unitName) -- spammy + local pos = unit:getPosition() + local newtbl = ldeepcopy(lunits[i]) + if pos then + newtbl.pos = pos.p + end + newtbl.unit = unit + --newtbl.rt_id = unit.id_ + lalive_units[unit.id_] = newtbl + updatedUnits[unit.id_] = true + end + end + if i%units_per_run == 0 then + coroutine.yield() + end + end + -- All units updated, remove any "alive" units that were not updated- they are dead! + for unit_id, unit in pairs(lalive_units) do + if not updatedUnits[unit_id] then + lremovedAliveUnits[unit_id] = unit + lalive_units[unit_id] = nil + end + end + end + end + + local function dbUpdate(event, objType) + dbLog:info('dbUpdate') + local newTable = {} + newTable.startTime = 0 + if type(event) == 'string' then -- if name of an object. + local newObject + if Group.getByName(event) then + newObject = Group.getByName(event) + elseif StaticObject.getByName(event) then + newObject = StaticObject.getByName(event) + -- log:info('its static') + else + log:warn('$1 is not a Unit or Static Object. This should not be possible', event) + return false + end + + newTable.name = newObject:getName() + newTable.groupId = tonumber(newObject:getID()) + newTable.groupName = newObject:getName() + local unitOneRef + if objType == 'static' then + unitOneRef = newObject + newTable.countryId = tonumber(newObject:getCountry()) + newTable.coalitionId = tonumber(newObject:getCoalition()) + newTable.category = 'static' + else + unitOneRef = newObject:getUnits() + newTable.countryId = tonumber(unitOneRef[1]:getCountry()) + newTable.coalitionId = tonumber(unitOneRef[1]:getCoalition()) + newTable.category = tonumber(newObject:getCategory()) + end + for countryData, countryId in pairs(country.id) do + if newTable.country and string.upper(countryData) == string.upper(newTable.country) or countryId == newTable.countryId then + newTable.countryId = countryId + newTable.country = string.lower(countryData) + for coaData, coaId in pairs(coalition.side) do + if coaId == coalition.getCountryCoalition(countryId) then + newTable.coalition = string.lower(coaData) + end + end + end + end + for catData, catId in pairs(Unit.Category) do + if objType == 'group' and Group.getByName(newTable.groupName):isExist() then + if catId == Group.getByName(newTable.groupName):getCategory() then + newTable.category = string.lower(catData) + end + elseif objType == 'static' and StaticObject.getByName(newTable.groupName):isExist() then + if catId == StaticObject.getByName(newTable.groupName):getCategory() then + newTable.category = string.lower(catData) + end + + end + end + local gfound = false + for index, data in pairs(mistAddedGroups) do + if mist.stringMatch(data.name, newTable.groupName) == true then + gfound = true + newTable.task = data.task + newTable.modulation = data.modulation + newTable.uncontrolled = data.uncontrolled + newTable.radioSet = data.radioSet + newTable.hidden = data.hidden + newTable.startTime = data.start_time + mistAddedGroups[index] = nil + end + end + + if gfound == false then + newTable.uncontrolled = false + newTable.hidden = false + end + + newTable.units = {} + if objType == 'group' then + for unitId, unitData in pairs(unitOneRef) do + newTable.units[unitId] = {} + newTable.units[unitId].unitName = unitData:getName() + + newTable.units[unitId].x = mist.utils.round(unitData:getPosition().p.x) + newTable.units[unitId].y = mist.utils.round(unitData:getPosition().p.z) + newTable.units[unitId].point = {} + newTable.units[unitId].point.x = newTable.units[unitId].x + newTable.units[unitId].point.y = newTable.units[unitId].y + newTable.units[unitId].alt = mist.utils.round(unitData:getPosition().p.y) + newTable.units[unitId].speed = mist.vec.mag(unitData:getVelocity()) + + newTable.units[unitId].heading = mist.getHeading(unitData, true) + + newTable.units[unitId].type = unitData:getTypeName() + newTable.units[unitId].unitId = tonumber(unitData:getID()) + + + newTable.units[unitId].groupName = newTable.groupName + newTable.units[unitId].groupId = newTable.groupId + newTable.units[unitId].countryId = newTable.countryId + newTable.units[unitId].coalitionId = newTable.coalitionId + newTable.units[unitId].coalition = newTable.coalition + newTable.units[unitId].country = newTable.country + local found = false + for index, data in pairs(mistAddedObjects) do + if mist.stringMatch(data.name, newTable.units[unitId].unitName) == true then + found = true + newTable.units[unitId].livery_id = data.livery_id + newTable.units[unitId].skill = data.skill + newTable.units[unitId].alt_type = data.alt_type + newTable.units[unitId].callsign = data.callsign + newTable.units[unitId].psi = data.psi + mistAddedObjects[index] = nil + end + if found == false then + newTable.units[unitId].skill = "High" + newTable.units[unitId].alt_type = "BARO" + end + end + + end + else -- its a static + newTable.category = 'static' + newTable.units[1] = {} + newTable.units[1].unitName = newObject:getName() + newTable.units[1].category = 'static' + newTable.units[1].x = mist.utils.round(newObject:getPosition().p.x) + newTable.units[1].y = mist.utils.round(newObject:getPosition().p.z) + newTable.units[1].point = {} + newTable.units[1].point.x = newTable.units[1].x + newTable.units[1].point.y = newTable.units[1].y + newTable.units[1].alt = mist.utils.round(newObject:getPosition().p.y) + newTable.units[1].heading = mist.getHeading(newObject, true) + newTable.units[1].type = newObject:getTypeName() + newTable.units[1].unitId = tonumber(newObject:getID()) + newTable.units[1].groupName = newTable.name + newTable.units[1].groupId = newTable.groupId + newTable.units[1].countryId = newTable.countryId + newTable.units[1].country = newTable.country + newTable.units[1].coalitionId = newTable.coalitionId + newTable.units[1].coalition = newTable.coalition + if newObject:getCategory() == 6 and newObject:getCargoDisplayName() then + local mass = newObject:getCargoDisplayName() + mass = string.gsub(mass, ' ', '') + mass = string.gsub(mass, 'kg', '') + newTable.units[1].mass = tonumber(mass) + newTable.units[1].categoryStatic = 'Cargos' + newTable.units[1].canCargo = true + newTable.units[1].shape_name = 'ab-212_cargo' + end + + ----- search mist added objects for extra data if applicable + for index, data in pairs(mistAddedObjects) do + if mist.stringMatch(data.name, newTable.units[1].unitName) == true then + newTable.units[1].shape_name = data.shape_name -- for statics + newTable.units[1].livery_id = data.livery_id + newTable.units[1].airdromeId = data.airdromeId + newTable.units[1].mass = data.mass + newTable.units[1].canCargo = data.canCargo + newTable.units[1].categoryStatic = data.categoryStatic + newTable.units[1].type = 'cargo1' + mistAddedObjects[index] = nil + end + end + end + end + --mist.debug.writeData(mist.utils.serialize,{'msg', newTable}, timer.getAbsTime() ..'Group.lua') + newTable.timeAdded = timer.getAbsTime() -- only on the dynGroupsAdded table. For other reference, see start time + --mist.debug.dumpDBs() + --end + dbLog:info('endDbUpdate') + return newTable + end + + --[[DB update code... FRACK. I need to refactor some of it. + + The problem is that the DBs need to account better for shared object names. Needs to write over some data and outright remove other. + + If groupName is used then entire group needs to be rewritten + what to do with old groups units DB entries?. Names cant be assumed to be the same. + + + -- new spawn event check. + -- event handler filters everything into groups: tempSpawnedGroups + -- this function then checks DBs to see if data has changed + ]] + local function checkSpawnedEventsNew() + if tempSpawnGroupsCounter > 0 then + --[[local updatesPerRun = math.ceil(#tempSpawnedGroupsCounter/20) + if updatesPerRun < 5 then + updatesPerRun = 5 + end]] + + dbLog:info('iterate') + for name, gType in pairs(tempSpawnedGroups) do + dbLog:info(name) + local updated = false + + if mist.DBs.groupsByName[name] then + -- first check group level properties, groupId, countryId, coalition + dbLog:info('Found in DBs, check if updated') + local dbTable = mist.DBs.groupsByName[name] + dbLog:info(dbTable) + if gType ~= 'static' then + dbLog:info('Not static') + local _g = Group.getByName(name) + local _u = _g:getUnit(1) + if dbTable.groupId ~= tonumber(_g:getID()) or _u:getCountry() ~= dbTable.countryId or _u:getCoalition() ~= dbTable.coaltionId then + dbLog:info('Group Data mismatch') + updated = true + else + dbLog:info('No Mismatch') + end + + end + end + dbLog:info('Updated: $1', updated) + if updated == false and gType ~= 'static' then -- time to check units + dbLog:info('No Group Mismatch, Check Units') + for index, uObject in pairs(Group.getByName(name):getUnits()) do + dbLog:info(index) + if mist.DBs.unitsByName[uObject:getName()] then + dbLog:info('UnitByName table exists') + local uTable = mist.DBs.unitsByName[uObject:getName()] + if tonumber(uObject:getID()) ~= uTable.unitId or uObject:getTypeName() ~= uTable.type then + dbLog:info('Unit Data mismatch') + updated = true + break + end + end + end + end + + if updated == true or not mist.DBs.groupsByName[name] then + dbLog:info('Get Table') + writeGroups[#writeGroups+1] = {data = dbUpdate(name, gType), isUpdated = updated} + + end + -- Work done, so remove + tempSpawnedGroups[name] = nil + tempSpawnGroupsCounter = tempSpawnGroupsCounter - 1 + end + end + end + + local function updateDBTables() + local i = #writeGroups + + local savesPerRun = math.ceil(i/10) + if savesPerRun < 5 then + savesPerRun = 5 + end + if i > 0 then + dbLog:info('updateDBTables') + local ldeepCopy = mist.utils.deepCopy + for x = 1, i do + dbLog:info(writeGroups[x]) + local newTable = writeGroups[x].data + local updated = writeGroups[x].isUpdated + local mistCategory + if type(newTable.category) == 'string' then + mistCategory = string.lower(newTable.category) + end + + if string.upper(newTable.category) == 'GROUND_UNIT' then + mistCategory = 'vehicle' + newTable.category = mistCategory + elseif string.upper(newTable.category) == 'AIRPLANE' then + mistCategory = 'plane' + newTable.category = mistCategory + elseif string.upper(newTable.category) == 'HELICOPTER' then + mistCategory = 'helicopter' + newTable.category = mistCategory + elseif string.upper(newTable.category) == 'SHIP' then + mistCategory = 'ship' + newTable.category = mistCategory + end + dbLog:info('Update unitsBy') + for newId, newUnitData in pairs(newTable.units) do + dbLog:info(newId) + newUnitData.category = mistCategory + if newUnitData.unitId then + dbLog:info('byId') + mist.DBs.unitsById[tonumber(newUnitData.unitId)] = ldeepCopy(newUnitData) + end + dbLog:info(updated) + if mist.DBs.unitsByName[newUnitData.unitName] and updated == true then--if unit existed before and something was updated, write over the entry for a given unit name just in case. + dbLog:info('Updating Unit Tables') + for i = 1, #mist.DBs.unitsByCat[mistCategory] do + if mist.DBs.unitsByCat[mistCategory][i].unitName == newUnitData.unitName then + dbLog:info('Entry Found, Rewriting for unitsByCat') + mist.DBs.unitsByCat[mistCategory][i] = ldeepCopy(newUnitData) + break + end + end + for i = 1, #mist.DBs.unitsByNum do + if mist.DBs.unitsByNum[i].unitName == newUnitData.unitName then + dbLog:info('Entry Found, Rewriting for unitsByNum') + mist.DBs.unitsByNum[i] = ldeepCopy(newUnitData) + break + end + end + + else + dbLog:info('Unitname not in use, add as normal') + mist.DBs.unitsByCat[mistCategory][#mist.DBs.unitsByCat[mistCategory] + 1] = ldeepCopy(newUnitData) + mist.DBs.unitsByNum[#mist.DBs.unitsByNum + 1] = ldeepCopy(newUnitData) + end + mist.DBs.unitsByName[newUnitData.unitName] = ldeepCopy(newUnitData) + + + end + -- this is a really annoying DB to populate. Gotta create new tables in case its missing + dbLog:info('write mist.DBs.units') + if not mist.DBs.units[newTable.coalition] then + mist.DBs.units[newTable.coalition] = {} + end + + if not mist.DBs.units[newTable.coalition][newTable.country] then + mist.DBs.units[newTable.coalition][(newTable.country)] = {} + mist.DBs.units[newTable.coalition][(newTable.country)].countryId = newTable.countryId + end + if not mist.DBs.units[newTable.coalition][newTable.country][mistCategory] then + mist.DBs.units[newTable.coalition][(newTable.country)][mistCategory] = {} + end + + if updated == true then + dbLog:info('Updating DBsUnits') + for i = 1, #mist.DBs.units[newTable.coalition][(newTable.country)][mistCategory] do + if mist.DBs.units[newTable.coalition][(newTable.country)][mistCategory][i].groupName == newTable.groupName then + dbLog:info('Entry Found, Rewriting') + mist.DBs.units[newTable.coalition][(newTable.country)][mistCategory][i] = ldeepCopy(newTable) + break + end + end + else + mist.DBs.units[newTable.coalition][(newTable.country)][mistCategory][#mist.DBs.units[newTable.coalition][(newTable.country)][mistCategory] + 1] = ldeepCopy(newTable) + end + + + if newTable.groupId then + mist.DBs.groupsById[newTable.groupId] = ldeepCopy(newTable) + end + + mist.DBs.groupsByName[newTable.name] = ldeepCopy(newTable) + mist.DBs.dynGroupsAdded[#mist.DBs.dynGroupsAdded + 1] = ldeepCopy(newTable) + + writeGroups[x] = nil + if x%savesPerRun == 0 then + coroutine.yield() + end + end + if timer.getTime() > lastUpdateTime then + lastUpdateTime = timer.getTime() + end + dbLog:info('endUpdateTables') + end + end + + local function groupSpawned(event) + -- dont need to add units spawned in at the start of the mission if mist is loaded in init line + if event.id == world.event.S_EVENT_BIRTH and timer.getTime0() < timer.getAbsTime() then + dbLog:info('unitSpawnEvent') + + --table.insert(tempSpawnedUnits,(event.initiator)) + ------- + -- New functionality below. + ------- + if Object.getCategory(event.initiator) == 1 and not Unit.getPlayerName(event.initiator) then -- simple player check, will need to later check to see if unit was spawned with a player in a flight + dbLog:info('Object is a Unit') + dbLog:info(Unit.getGroup(event.initiator):getName()) + if not tempSpawnedGroups[Unit.getGroup(event.initiator):getName()] then + dbLog:info('added') + tempSpawnedGroups[Unit.getGroup(event.initiator):getName()] = 'group' + tempSpawnGroupsCounter = tempSpawnGroupsCounter + 1 + end + elseif Object.getCategory(event.initiator) == 3 or Object.getCategory(event.initiator) == 6 then + dbLog:info('Object is Static') + tempSpawnedGroups[StaticObject.getName(event.initiator)] = 'static' + tempSpawnGroupsCounter = tempSpawnGroupsCounter + 1 + end + + + end + end + + local function doScheduledFunctions() + local i = 1 + while i <= #scheduledTasks do + if not scheduledTasks[i].rep then -- not a repeated process + if scheduledTasks[i].t <= timer.getTime() then + local task = scheduledTasks[i] -- local reference + table.remove(scheduledTasks, i) + local err, errmsg = pcall(task.f, unpack(task.vars, 1, table.maxn(task.vars))) + if not err then + log:error('Error in scheduled function: $1', errmsg) + end + --task.f(unpack(task.vars, 1, table.maxn(task.vars))) -- do the task, do not increment i + else + i = i + 1 + end + else + if scheduledTasks[i].st and scheduledTasks[i].st <= timer.getTime() then --if a stoptime was specified, and the stop time exceeded + table.remove(scheduledTasks, i) -- stop time exceeded, do not execute, do not increment i + elseif scheduledTasks[i].t <= timer.getTime() then + local task = scheduledTasks[i] -- local reference + task.t = timer.getTime() + task.rep --schedule next run + local err, errmsg = pcall(task.f, unpack(task.vars, 1, table.maxn(task.vars))) + if not err then + log:error('Error in scheduled function: $1' .. errmsg) + end + --scheduledTasks[i].f(unpack(scheduledTasks[i].vars, 1, table.maxn(scheduledTasks[i].vars))) -- do the task + i = i + 1 + else + i = i + 1 + end + end + end + end + + -- Event handler to start creating the dead_objects table + local function addDeadObject(event) + if event.id == world.event.S_EVENT_DEAD or event.id == world.event.S_EVENT_CRASH then + if event.initiator and event.initiator.id_ and event.initiator.id_ > 0 then + + local id = event.initiator.id_ -- initial ID, could change if there is a duplicate id_ already dead. + local val = {object = event.initiator} -- the new entry in mist.DBs.deadObjects. + + local original_id = id --only for duplicate runtime IDs. + local id_ind = 1 + while mist.DBs.deadObjects[id] do + --log:info('duplicate runtime id of previously dead object id: $1', id) + id = tostring(original_id) .. ' #' .. tostring(id_ind) + id_ind = id_ind + 1 + end + + if mist.DBs.aliveUnits and mist.DBs.aliveUnits[val.object.id_] then + --log:info('object found in alive_units') + val.objectData = mist.utils.deepCopy(mist.DBs.aliveUnits[val.object.id_]) + local pos = Object.getPosition(val.object) + if pos then + val.objectPos = pos.p + end + val.objectType = mist.DBs.aliveUnits[val.object.id_].category + --[[if mist.DBs.activeHumans[Unit.getName(val.object)] then + --trigger.action.outText('remove via death: ' .. Unit.getName(val.object),20) + mist.DBs.activeHumans[Unit.getName(val.object)] = nil + end]] + elseif mist.DBs.removedAliveUnits and mist.DBs.removedAliveUnits[val.object.id_] then -- it didn't exist in alive_units, check old_alive_units + --log:info('object found in old_alive_units') + val.objectData = mist.utils.deepCopy(mist.DBs.removedAliveUnits[val.object.id_]) + local pos = Object.getPosition(val.object) + if pos then + val.objectPos = pos.p + end + val.objectType = mist.DBs.removedAliveUnits[val.object.id_].category + + else --attempt to determine if static object... + --log:info('object not found in alive units or old alive units') + local pos = Object.getPosition(val.object) + if pos then + local static_found = false + for ind, static in pairs(mist.DBs.unitsByCat.static) do + if ((pos.p.x - static.point.x)^2 + (pos.p.z - static.point.y)^2)^0.5 < 0.1 then --really, it should be zero... + --log:info('correlated dead static object to position') + val.objectData = static + val.objectPos = pos.p + val.objectType = 'static' + static_found = true + break + end + end + if not static_found then + val.objectPos = pos.p + val.objectType = 'building' + end + else + val.objectType = 'unknown' + end + end + mist.DBs.deadObjects[id] = val + end + end + end + + --[[ + local function addClientsToActive(event) + if event.id == world.event.S_EVENT_PLAYER_ENTER_UNIT or event.id == world.event.S_EVENT_BIRTH then + log:info(event) + if Unit.getPlayerName(event.initiator) then + log:info(Unit.getPlayerName(event.initiator)) + local newU = mist.utils.deepCopy(mist.DBs.unitsByName[Unit.getName(event.initiator)]) + newU.playerName = Unit.getPlayerName(event.initiator) + mist.DBs.activeHumans[Unit.getName(event.initiator)] = newU + --trigger.action.outText('added: ' .. Unit.getName(event.initiator), 20) + end + elseif event.id == world.event.S_EVENT_PLAYER_LEAVE_UNIT and event.initiator then + if mist.DBs.activeHumans[Unit.getName(event.initiator)] then + mist.DBs.activeHumans[Unit.getName(event.initiator)] = nil + -- trigger.action.outText('removed via control: ' .. Unit.getName(event.initiator), 20) + end + end + end + + mist.addEventHandler(addClientsToActive) + ]] + + --- init function. + -- creates logger, adds default event handler + -- and calls main the first time. + -- @function mist.init + function mist.init() + -- create logger + mist.log = mist.Logger:new("MIST") + dbLog = mist.Logger:new('MISTDB', 'warning') + + log = mist.log -- log shorthand + -- set warning log level, showing only + -- warnings and errors + log:setLevel("warning") + + log:info("initializing databases") + initDBs() + + -- add event handler for group spawns + mist.addEventHandler(groupSpawned) + mist.addEventHandler(addDeadObject) + + -- call main the first time therafter it reschedules itself. + mist.main() + --log:msg('MIST version $1.$2.$3 loaded', mist.majorVersion, mist.minorVersion, mist.build) + return + end + + --- The main function. + -- Run 100 times per second. + -- You shouldn't call this function. + function mist.main() + timer.scheduleFunction(mist.main, {}, timer.getTime() + 0.01) --reschedule first in case of Lua error + + updateTenthSecond = updateTenthSecond + 1 + if updateTenthSecond == 10 then + updateTenthSecond = 0 + + checkSpawnedEventsNew() + + if not coroutines.updateDBTables then + coroutines.updateDBTables = coroutine.create(updateDBTables) + end + + coroutine.resume(coroutines.updateDBTables) + + if coroutine.status(coroutines.updateDBTables) == 'dead' then + coroutines.updateDBTables = nil + end + end + + --updating alive units + updateAliveUnitsCounter = updateAliveUnitsCounter + 1 + if updateAliveUnitsCounter == 5 then + updateAliveUnitsCounter = 0 + + if not coroutines.updateAliveUnits then + coroutines.updateAliveUnits = coroutine.create(updateAliveUnits) + end + + coroutine.resume(coroutines.updateAliveUnits) + + if coroutine.status(coroutines.updateAliveUnits) == 'dead' then + coroutines.updateAliveUnits = nil + end + end + + doScheduledFunctions() + end -- end of mist.main + + --- Returns next unit id. + -- @treturn number next unit id. + function mist.getNextUnitId() + mist.nextUnitId = mist.nextUnitId + 1 + if mist.nextUnitId > 6900 then + mist.nextUnitId = 14000 + end + return mist.nextUnitId + end + + --- Returns next group id. + -- @treturn number next group id. + function mist.getNextGroupId() + mist.nextGroupId = mist.nextGroupId + 1 + if mist.nextGroupId > 6900 then + mist.nextGroupId = 14000 + end + return mist.nextGroupId + end + + --- Returns timestamp of last database update. + -- @treturn timestamp of last database update + function mist.getLastDBUpdateTime() + return lastUpdateTime + end + + --- Spawns a static object to the game world. + -- @todo write good docs + -- @tparam table staticObj table containing data needed for the object creation + function mist.dynAddStatic(newObj) + + if newObj.units and newObj.units[1] then -- if its mist format + for entry, val in pairs(newObj.units[1]) do + if newObj[entry] and newObj[entry] ~= val or not newObj[entry] then + newObj[entry] = val + end + end + end + --log:info(newObj) + + local cntry = newObj.country + if newObj.countryId then + cntry = newObj.countryId + end + + local newCountry = '' + + for countryId, countryName in pairs(country.name) do + if type(cntry) == 'string' then + cntry = cntry:gsub("%s+", "_") + if tostring(countryName) == string.upper(cntry) then + newCountry = countryName + end + elseif type(cntry) == 'number' then + if countryId == cntry then + newCountry = countryName + end + end + end + + if newCountry == '' then + log:error("Country not found: $1", cntry) + return false + end + + if newObj.clone or not newObj.groupId then + mistGpId = mistGpId + 1 + newObj.groupId = mistGpId + end + + if newObj.clone or not newObj.unitId then + mistUnitId = mistUnitId + 1 + newObj.unitId = mistUnitId + end + + if newObj.clone or not newObj.name then + mistDynAddIndex[' static '] = mistDynAddIndex[' static '] + 1 + newObj.name = (newCountry .. ' static ' .. mistDynAddIndex[' static ']) + end + + if not newObj.dead then + newObj.dead = false + end + + if not newObj.heading then + newObj.heading = math.random(360) + end + + if newObj.categoryStatic then + newObj.category = newObj.categoryStatic + end + if newObj.mass then + newObj.category = 'Cargos' + end + + if newObj.shapeName then + newObj.shape_name = newObj.shapeName + end + + if not newObj.shape_name then + log:info('shape_name not present') + if mist.DBs.const.shapeNames[newObj.type] then + newObj.shape_name = mist.DBs.const.shapeNames[newObj.type] + end + end + + mistAddedObjects[#mistAddedObjects + 1] = mist.utils.deepCopy(newObj) + if newObj.x and newObj.y and newObj.type and type(newObj.x) == 'number' and type(newObj.y) == 'number' and type(newObj.type) == 'string' then + --log:info('addStaticObject') + coalition.addStaticObject(country.id[newCountry], newObj) + + return newObj + end + log:error("Failed to add static object due to missing or incorrect value. X: $1, Y: $2, Type: $3", newObj.x, newObj.y, newObj.type) + return false + end + + --- Spawns a dynamic group into the game world. + -- Same as coalition.add function in SSE. checks the passed data to see if its valid. + -- Will generate groupId, groupName, unitId, and unitName if needed + -- @tparam table newGroup table containting values needed for spawning a group. + function mist.dynAdd(newGroup) + + --mist.debug.writeData(mist.utils.serialize,{'msg', newGroup}, 'newGroupOrig.lua') + local cntry = newGroup.country + if newGroup.countryId then + cntry = newGroup.countryId + end + + local groupType = newGroup.category + local newCountry = '' + -- validate data + for countryId, countryName in pairs(country.name) do + if type(cntry) == 'string' then + cntry = cntry:gsub("%s+", "_") + if tostring(countryName) == string.upper(cntry) then + newCountry = countryName + end + elseif type(cntry) == 'number' then + if countryId == cntry then + newCountry = countryName + end + end + end + + if newCountry == '' then + log:error("Country not found: $1", cntry) + return false + end + + local newCat = '' + for catName, catId in pairs(Unit.Category) do + if type(groupType) == 'string' then + if tostring(catName) == string.upper(groupType) then + newCat = catName + end + elseif type(groupType) == 'number' then + if catId == groupType then + newCat = catName + end + end + + if catName == 'GROUND_UNIT' and (string.upper(groupType) == 'VEHICLE' or string.upper(groupType) == 'GROUND') then + newCat = 'GROUND_UNIT' + elseif catName == 'AIRPLANE' and string.upper(groupType) == 'PLANE' then + newCat = 'AIRPLANE' + end + end + local typeName + if newCat == 'GROUND_UNIT' then + typeName = ' gnd ' + elseif newCat == 'AIRPLANE' then + typeName = ' air ' + elseif newCat == 'HELICOPTER' then + typeName = ' hel ' + elseif newCat == 'SHIP' then + typeName = ' shp ' + elseif newCat == 'BUILDING' then + typeName = ' bld ' + end + if newGroup.clone or not newGroup.groupId then + mistDynAddIndex[typeName] = mistDynAddIndex[typeName] + 1 + mistGpId = mistGpId + 1 + newGroup.groupId = mistGpId + end + if newGroup.groupName or newGroup.name then + if newGroup.groupName then + newGroup.name = newGroup.groupName + elseif newGroup.name then + newGroup.name = newGroup.name + end + end + + if newGroup.clone and mist.DBs.groupsByName[newGroup.name] or not newGroup.name then + newGroup.name = tostring(newCountry .. tostring(typeName) .. mistDynAddIndex[typeName]) + end + + if not newGroup.hidden then + newGroup.hidden = false + end + + if not newGroup.visible then + newGroup.visible = false + end + + if (newGroup.start_time and type(newGroup.start_time) ~= 'number') or not newGroup.start_time then + if newGroup.startTime then + newGroup.start_time = mist.utils.round(newGroup.startTime) + else + newGroup.start_time = 0 + end + end + + + for unitIndex, unitData in pairs(newGroup.units) do + local originalName = newGroup.units[unitIndex].unitName or newGroup.units[unitIndex].name + if newGroup.clone or not unitData.unitId then + mistUnitId = mistUnitId + 1 + newGroup.units[unitIndex].unitId = mistUnitId + end + if newGroup.units[unitIndex].unitName or newGroup.units[unitIndex].name then + if newGroup.units[unitIndex].unitName then + newGroup.units[unitIndex].name = newGroup.units[unitIndex].unitName + elseif newGroup.units[unitIndex].name then + newGroup.units[unitIndex].name = newGroup.units[unitIndex].name + end + end + if newGroup.clone or not unitData.name then + newGroup.units[unitIndex].name = tostring(newGroup.name .. ' unit' .. unitIndex) + end + + if not unitData.skill then + newGroup.units[unitIndex].skill = 'Random' + end + + if not unitData.alt then + if newCat == 'AIRPLANE' then + newGroup.units[unitIndex].alt = 2000 + newGroup.units[unitIndex].alt_type = 'RADIO' + newGroup.units[unitIndex].speed = 150 + elseif newCat == 'HELICOPTER' then + newGroup.units[unitIndex].alt = 500 + newGroup.units[unitIndex].alt_type = 'RADIO' + newGroup.units[unitIndex].speed = 60 + else + --[[log:info('check height') + newGroup.units[unitIndex].alt = land.getHeight({x = newGroup.units[unitIndex].x, y = newGroup.units[unitIndex].y}) + newGroup.units[unitIndex].alt_type = 'BARO']] + end + + + end + + if newCat == 'AIRPLANE' or newCat == 'HELICOPTER' then + if newGroup.units[unitIndex].alt_type and newGroup.units[unitIndex].alt_type ~= 'BARO' or not newGroup.units[unitIndex].alt_type then + newGroup.units[unitIndex].alt_type = 'RADIO' + end + if not unitData.speed then + if newCat == 'AIRPLANE' then + newGroup.units[unitIndex].speed = 150 + elseif newCat == 'HELICOPTER' then + newGroup.units[unitIndex].speed = 60 + end + end + if not unitData.payload then + newGroup.units[unitIndex].payload = mist.getPayload(originalName) + end + end + mistAddedObjects[#mistAddedObjects + 1] = mist.utils.deepCopy(newGroup.units[unitIndex]) + end + mistAddedGroups[#mistAddedGroups + 1] = mist.utils.deepCopy(newGroup) + if newGroup.route and not newGroup.route.points then + if not newGroup.route.points and newGroup.route[1] then + local copyRoute = newGroup.route + newGroup.route = {} + newGroup.route.points = copyRoute + end + end + newGroup.country = newCountry + + + --mist.debug.writeData(mist.utils.serialize,{'msg', newGroup}, 'newGroup.lua') + + -- sanitize table + newGroup.groupName = nil + newGroup.clone = nil + newGroup.category = nil + newGroup.country = nil + + newGroup.tasks = {} + + for unitIndex, unitData in pairs(newGroup.units) do + newGroup.units[unitIndex].unitName = nil + end + + coalition.addGroup(country.id[newCountry], Unit.Category[newCat], newGroup) + + return newGroup + + end + + --- Schedules a function. + -- Modified Slmod task scheduler, superior to timer.scheduleFunction + -- @tparam function f function to schedule + -- @tparam table vars array containing all parameters passed to the function + -- @tparam number t time in seconds from mission start to schedule the function to. + -- @tparam[opt] number rep time between repetitions of the function + -- @tparam[opt] number st time in seconds from mission start at which the function + -- should stop to be rescheduled. + -- @treturn number scheduled function id. + function mist.scheduleFunction(f, vars, t, rep, st) + --verify correct types + assert(type(f) == 'function', 'variable 1, expected function, got ' .. type(f)) + assert(type(vars) == 'table' or vars == nil, 'variable 2, expected table or nil, got ' .. type(f)) + assert(type(t) == 'number', 'variable 3, expected number, got ' .. type(t)) + assert(type(rep) == 'number' or rep == nil, 'variable 4, expected number or nil, got ' .. type(rep)) + assert(type(st) == 'number' or st == nil, 'variable 5, expected number or nil, got ' .. type(st)) + if not vars then + vars = {} + end + taskId = taskId + 1 + table.insert(scheduledTasks, {f = f, vars = vars, t = t, rep = rep, st = st, id = taskId}) + return taskId + end + + --- Removes a scheduled function. + -- @tparam number id function id + -- @treturn boolean true if function was successfully removed, false otherwise. + function mist.removeFunction(id) + local i = 1 + while i <= #scheduledTasks do + if scheduledTasks[i].id == id then + table.remove(scheduledTasks, i) + else + i = i + 1 + end + end + end + + --- Registers an event handler. + -- @tparam function f function handling event + -- @treturn number id of the event handler + function mist.addEventHandler(f) --id is optional! + local handler = {} + idNum = idNum + 1 + handler.id = idNum + handler.f = f + function handler:onEvent(event) + self.f(event) + end + world.addEventHandler(handler) + return handler.id + end + + --- Removes event handler with given id. + -- @tparam number id event handler id + -- @treturn boolean true on success, false otherwise + function mist.removeEventHandler(id) + for key, handler in pairs(world.eventHandlers) do + if handler.id and handler.id == id then + world.eventHandlers[key] = nil + return true + end + end + return false + end +end + +-- Begin common funcs +do + --- Returns MGRS coordinates as string. + -- @tparam string MGRS MGRS coordinates + -- @tparam number acc the accuracy of each easting/northing. + -- Can be: 0, 1, 2, 3, 4, or 5. + function mist.tostringMGRS(MGRS, acc) + if acc == 0 then + return MGRS.UTMZone .. ' ' .. MGRS.MGRSDigraph + else + return MGRS.UTMZone .. ' ' .. MGRS.MGRSDigraph .. ' ' .. string.format('%0' .. acc .. 'd', mist.utils.round(MGRS.Easting/(10^(5-acc)), 0)) + .. ' ' .. string.format('%0' .. acc .. 'd', mist.utils.round(MGRS.Northing/(10^(5-acc)), 0)) + end + end + + --[[acc: + in DM: decimal point of minutes. + In DMS: decimal point of seconds. + position after the decimal of the least significant digit: + So: + 42.32 - acc of 2. + ]] + function mist.tostringLL(lat, lon, acc, DMS) + + local latHemi, lonHemi + if lat > 0 then + latHemi = 'N' + else + latHemi = 'S' + end + + if lon > 0 then + lonHemi = 'E' + else + lonHemi = 'W' + end + + lat = math.abs(lat) + lon = math.abs(lon) + + local latDeg = math.floor(lat) + local latMin = (lat - latDeg)*60 + + local lonDeg = math.floor(lon) + local lonMin = (lon - lonDeg)*60 + + if DMS then -- degrees, minutes, and seconds. + local oldLatMin = latMin + latMin = math.floor(latMin) + local latSec = mist.utils.round((oldLatMin - latMin)*60, acc) + + local oldLonMin = lonMin + lonMin = math.floor(lonMin) + local lonSec = mist.utils.round((oldLonMin - lonMin)*60, acc) + + if latSec == 60 then + latSec = 0 + latMin = latMin + 1 + end + + if lonSec == 60 then + lonSec = 0 + lonMin = lonMin + 1 + end + + local secFrmtStr -- create the formatting string for the seconds place + if acc <= 0 then -- no decimal place. + secFrmtStr = '%02d' + else + local width = 3 + acc -- 01.310 - that's a width of 6, for example. + secFrmtStr = '%0' .. width .. '.' .. acc .. 'f' + end + + return string.format('%02d', latDeg) .. ' ' .. string.format('%02d', latMin) .. '\' ' .. string.format(secFrmtStr, latSec) .. '"' .. latHemi .. ' ' + .. string.format('%02d', lonDeg) .. ' ' .. string.format('%02d', lonMin) .. '\' ' .. string.format(secFrmtStr, lonSec) .. '"' .. lonHemi + + else -- degrees, decimal minutes. + latMin = mist.utils.round(latMin, acc) + lonMin = mist.utils.round(lonMin, acc) + + if latMin == 60 then + latMin = 0 + latDeg = latDeg + 1 + end + + if lonMin == 60 then + lonMin = 0 + lonDeg = lonDeg + 1 + end + + local minFrmtStr -- create the formatting string for the minutes place + if acc <= 0 then -- no decimal place. + minFrmtStr = '%02d' + else + local width = 3 + acc -- 01.310 - that's a width of 6, for example. + minFrmtStr = '%0' .. width .. '.' .. acc .. 'f' + end + + return string.format('%02d', latDeg) .. ' ' .. string.format(minFrmtStr, latMin) .. '\'' .. latHemi .. ' ' + .. string.format('%02d', lonDeg) .. ' ' .. string.format(minFrmtStr, lonMin) .. '\'' .. lonHemi + + end + end + + --[[ required: az - radian + required: dist - meters + optional: alt - meters (set to false or nil if you don't want to use it). + optional: metric - set true to get dist and alt in km and m. + precision will always be nearest degree and NM or km.]] + function mist.tostringBR(az, dist, alt, metric) + az = mist.utils.round(mist.utils.toDegree(az), 0) + + if metric then + dist = mist.utils.round(dist/1000, 0) + else + dist = mist.utils.round(mist.utils.metersToNM(dist), 0) + end + + local s = string.format('%03d', az) .. ' for ' .. dist + + if alt then + if metric then + s = s .. ' at ' .. mist.utils.round(alt, 0) + else + s = s .. ' at ' .. mist.utils.round(mist.utils.metersToFeet(alt), 0) + end + end + return s + end + + function mist.getNorthCorrection(gPoint) --gets the correction needed for true north + local point = mist.utils.deepCopy(gPoint) + if not point.z then --Vec2; convert to Vec3 + point.z = point.y + point.y = 0 + end + local lat, lon = coord.LOtoLL(point) + local north_posit = coord.LLtoLO(lat + 1, lon) + return math.atan2(north_posit.z - point.z, north_posit.x - point.x) + end + + --- Returns skill of the given unit. + -- @tparam string unitName unit name + -- @return skill of the unit + function mist.getUnitSkill(unitName) + if mist.DBs.unitsByName[unitName] then + if Unit.getByName(unitName) then + local lunit = Unit.getByName(unitName) + local data = mist.DBs.unitsByName[unitName] + if data.unitName == unitName and data.type == lunit:getTypeName() and data.unitId == tonumber(lunit:getID()) and data.skill then + return data.skill + end + end + end + log:error("Unit not found in DB: $1", unitName) + return false + end + + --- Returns an array containing a group's units positions. + -- e.g. + -- { + -- [1] = {x = 299435.224, y = -1146632.6773}, + -- [2] = {x = 663324.6563, y = 322424.1112} + -- } + -- @tparam number|string groupIdent group id or name + -- @treturn table array containing positions of each group member + function mist.getGroupPoints(groupIdent) + -- search by groupId and allow groupId and groupName as inputs + local gpId = groupIdent + if type(groupIdent) == 'string' and not tonumber(groupIdent) then + if mist.DBs.MEgroupsByName[groupIdent] then + gpId = mist.DBs.MEgroupsByName[groupIdent].groupId + else + log:error("Group not found in mist.DBs.MEgroupsByName: $1", groupIdent) + end + end + + for coa_name, coa_data in pairs(env.mission.coalition) do + if (coa_name == 'red' or coa_name == 'blue') and type(coa_data) == 'table' then + if coa_data.country then --there is a country table + for cntry_id, cntry_data in pairs(coa_data.country) do + for obj_type_name, obj_type_data in pairs(cntry_data) do + if obj_type_name == "helicopter" or obj_type_name == "ship" or obj_type_name == "plane" or obj_type_name == "vehicle" then -- only these types have points + if ((type(obj_type_data) == 'table') and obj_type_data.group and (type(obj_type_data.group) == 'table') and (#obj_type_data.group > 0)) then --there's a group! + for group_num, group_data in pairs(obj_type_data.group) do + if group_data and group_data.groupId == gpId then -- this is the group we are looking for + if group_data.route and group_data.route.points and #group_data.route.points > 0 then + local points = {} + for point_num, point in pairs(group_data.route.points) do + if not point.point then + points[point_num] = { x = point.x, y = point.y } + else + points[point_num] = point.point --it's possible that the ME could move to the point = Vec2 notation. + end + end + return points + end + return + end --if group_data and group_data.name and group_data.name == 'groupname' + end --for group_num, group_data in pairs(obj_type_data.group) do + end --if ((type(obj_type_data) == 'table') and obj_type_data.group and (type(obj_type_data.group) == 'table') and (#obj_type_data.group > 0)) then + end --if obj_type_name == "helicopter" or obj_type_name == "ship" or obj_type_name == "plane" or obj_type_name == "vehicle" or obj_type_name == "static" then + end --for obj_type_name, obj_type_data in pairs(cntry_data) do + end --for cntry_id, cntry_data in pairs(coa_data.country) do + end --if coa_data.country then --there is a country table + end --if coa_name == 'red' or coa_name == 'blue' and type(coa_data) == 'table' then + end --for coa_name, coa_data in pairs(mission.coalition) do + end + + --- getUnitAttitude(unit) return values. + -- Yaw, AoA, ClimbAngle - relative to earth reference + -- DOES NOT TAKE INTO ACCOUNT WIND. + -- @table attitude + -- @tfield number Heading in radians, range of 0 to 2*pi, + -- relative to true north. + -- @tfield number Pitch in radians, range of -pi/2 to pi/2 + -- @tfield number Roll in radians, range of 0 to 2*pi, + -- right roll is positive direction. + -- @tfield number Yaw in radians, range of -pi to pi, + -- right yaw is positive direction. + -- @tfield number AoA in radians, range of -pi to pi, + -- rotation of aircraft to the right in comparison to + -- flight direction being positive. + -- @tfield number ClimbAngle in radians, range of -pi/2 to pi/2 + + --- Returns the attitude of a given unit. + -- Will work on any unit, even if not an aircraft. + -- @tparam Unit unit unit whose attitude is returned. + -- @treturn table @{attitude} + function mist.getAttitude(unit) + local unitpos = unit:getPosition() + if unitpos then + + local Heading = math.atan2(unitpos.x.z, unitpos.x.x) + + Heading = Heading + mist.getNorthCorrection(unitpos.p) + + if Heading < 0 then + Heading = Heading + 2*math.pi -- put heading in range of 0 to 2*pi + end + ---- heading complete.---- + + local Pitch = math.asin(unitpos.x.y) + ---- pitch complete.---- + + -- now get roll: + --maybe not the best way to do it, but it works. + + --first, make a vector that is perpendicular to y and unitpos.x with cross product + local cp = mist.vec.cp(unitpos.x, {x = 0, y = 1, z = 0}) + + --now, get dot product of of this cross product with unitpos.z + local dp = mist.vec.dp(cp, unitpos.z) + + --now get the magnitude of the roll (magnitude of the angle between two vectors is acos(vec1.vec2/|vec1||vec2|) + local Roll = math.acos(dp/(mist.vec.mag(cp)*mist.vec.mag(unitpos.z))) + + --now, have to get sign of roll. + -- by convention, making right roll positive + -- to get sign of roll, use the y component of unitpos.z. For right roll, y component is negative. + + if unitpos.z.y > 0 then -- left roll, flip the sign of the roll + Roll = -Roll + end + ---- roll complete. ---- + + --now, work on yaw, AoA, climb, and abs velocity + local Yaw + local AoA + local ClimbAngle + + -- get unit velocity + local unitvel = unit:getVelocity() + if mist.vec.mag(unitvel) ~= 0 then --must have non-zero velocity! + local AxialVel = {} --unit velocity transformed into aircraft axes directions + + --transform velocity components in direction of aircraft axes. + AxialVel.x = mist.vec.dp(unitpos.x, unitvel) + AxialVel.y = mist.vec.dp(unitpos.y, unitvel) + AxialVel.z = mist.vec.dp(unitpos.z, unitvel) + + --Yaw is the angle between unitpos.x and the x and z velocities + --define right yaw as positive + Yaw = math.acos(mist.vec.dp({x = 1, y = 0, z = 0}, {x = AxialVel.x, y = 0, z = AxialVel.z})/mist.vec.mag({x = AxialVel.x, y = 0, z = AxialVel.z})) + + --now set correct direction: + if AxialVel.z > 0 then + Yaw = -Yaw + end + + -- AoA is angle between unitpos.x and the x and y velocities + AoA = math.acos(mist.vec.dp({x = 1, y = 0, z = 0}, {x = AxialVel.x, y = AxialVel.y, z = 0})/mist.vec.mag({x = AxialVel.x, y = AxialVel.y, z = 0})) + + --now set correct direction: + if AxialVel.y > 0 then + AoA = -AoA + end + + ClimbAngle = math.asin(unitvel.y/mist.vec.mag(unitvel)) + end + return { Heading = Heading, Pitch = Pitch, Roll = Roll, Yaw = Yaw, AoA = AoA, ClimbAngle = ClimbAngle} + else + log:error("Couldn't get unit's position") + end + end + + --- Returns heading of given unit. + -- @tparam Unit unit unit whose heading is returned. + -- @param rawHeading + -- @treturn number heading of the unit, in range + -- of 0 to 2*pi. + function mist.getHeading(unit, rawHeading) + local unitpos = unit:getPosition() + if unitpos then + local Heading = math.atan2(unitpos.x.z, unitpos.x.x) + if not rawHeading then + Heading = Heading + mist.getNorthCorrection(unitpos.p) + end + if Heading < 0 then + Heading = Heading + 2*math.pi -- put heading in range of 0 to 2*pi + end + return Heading + end + end + + --- Returns given unit's pitch + -- @tparam Unit unit unit whose pitch is returned. + -- @treturn number pitch of given unit + function mist.getPitch(unit) + local unitpos = unit:getPosition() + if unitpos then + return math.asin(unitpos.x.y) + end + end + + --- Returns given unit's roll. + -- @tparam Unit unit unit whose roll is returned. + -- @treturn number roll of given unit + function mist.getRoll(unit) + local unitpos = unit:getPosition() + if unitpos then + -- now get roll: + --maybe not the best way to do it, but it works. + + --first, make a vector that is perpendicular to y and unitpos.x with cross product + local cp = mist.vec.cp(unitpos.x, {x = 0, y = 1, z = 0}) + + --now, get dot product of of this cross product with unitpos.z + local dp = mist.vec.dp(cp, unitpos.z) + + --now get the magnitude of the roll (magnitude of the angle between two vectors is acos(vec1.vec2/|vec1||vec2|) + local Roll = math.acos(dp/(mist.vec.mag(cp)*mist.vec.mag(unitpos.z))) + + --now, have to get sign of roll. + -- by convention, making right roll positive + -- to get sign of roll, use the y component of unitpos.z. For right roll, y component is negative. + + if unitpos.z.y > 0 then -- left roll, flip the sign of the roll + Roll = -Roll + end + return Roll + end + end + + --- Returns given unit's yaw. + -- @tparam Unit unit unit whose yaw is returned. + -- @treturn number yaw of given unit. + function mist.getYaw(unit) + local unitpos = unit:getPosition() + if unitpos then + -- get unit velocity + local unitvel = unit:getVelocity() + if mist.vec.mag(unitvel) ~= 0 then --must have non-zero velocity! + local AxialVel = {} --unit velocity transformed into aircraft axes directions + + --transform velocity components in direction of aircraft axes. + AxialVel.x = mist.vec.dp(unitpos.x, unitvel) + AxialVel.y = mist.vec.dp(unitpos.y, unitvel) + AxialVel.z = mist.vec.dp(unitpos.z, unitvel) + + --Yaw is the angle between unitpos.x and the x and z velocities + --define right yaw as positive + local Yaw = math.acos(mist.vec.dp({x = 1, y = 0, z = 0}, {x = AxialVel.x, y = 0, z = AxialVel.z})/mist.vec.mag({x = AxialVel.x, y = 0, z = AxialVel.z})) + + --now set correct direction: + if AxialVel.z > 0 then + Yaw = -Yaw + end + return Yaw + end + end + end + + --- Returns given unit's angle of attack. + -- @tparam Unit unit unit to get AoA from. + -- @treturn number angle of attack of the given unit. + function mist.getAoA(unit) + local unitpos = unit:getPosition() + if unitpos then + local unitvel = unit:getVelocity() + if mist.vec.mag(unitvel) ~= 0 then --must have non-zero velocity! + local AxialVel = {} --unit velocity transformed into aircraft axes directions + + --transform velocity components in direction of aircraft axes. + AxialVel.x = mist.vec.dp(unitpos.x, unitvel) + AxialVel.y = mist.vec.dp(unitpos.y, unitvel) + AxialVel.z = mist.vec.dp(unitpos.z, unitvel) + + -- AoA is angle between unitpos.x and the x and y velocities + local AoA = math.acos(mist.vec.dp({x = 1, y = 0, z = 0}, {x = AxialVel.x, y = AxialVel.y, z = 0})/mist.vec.mag({x = AxialVel.x, y = AxialVel.y, z = 0})) + + --now set correct direction: + if AxialVel.y > 0 then + AoA = -AoA + end + return AoA + end + end + end + + --- Returns given unit's climb angle. + -- @tparam Unit unit unit to get climb angle from. + -- @treturn number climb angle of given unit. + function mist.getClimbAngle(unit) + local unitpos = unit:getPosition() + if unitpos then + local unitvel = unit:getVelocity() + if mist.vec.mag(unitvel) ~= 0 then --must have non-zero velocity! + return math.asin(unitvel.y/mist.vec.mag(unitvel)) + end + end + end + + --[[-- + Unit name table. + Many Mist functions require tables of unit names, which are known + in Mist as UnitNameTables. These follow a special set of shortcuts + borrowed from Slmod. These shortcuts alleviate the problem of entering + huge lists of unit names by hand, and in many cases, they remove the + need to even know the names of the units in the first place! + + These are the unit table "short-cut" commands: + + Prefixes: + "[-u]" - subtract this unit if its in the table + "[g]" - add this group to the table + "[-g]" - subtract this group from the table + "[c]" - add this country's units + "[-c]" - subtract this country's units if any are in the table + + Stand-alone identifiers + "[all]" - add all units + "[-all]" - subtract all units (not very useful by itself) + "[blue]" - add all blue units + "[-blue]" - subtract all blue units + "[red]" - add all red coalition units + "[-red]" - subtract all red units + + Compound Identifiers: + "[c][helicopter]" - add all of this country's helicopters + "[-c][helicopter]" - subtract all of this country's helicopters + "[c][plane]" - add all of this country's planes + "[-c][plane]" - subtract all of this country's planes + "[c][ship]" - add all of this country's ships + "[-c][ship]" - subtract all of this country's ships + "[c][vehicle]" - add all of this country's vehicles + "[-c][vehicle]" - subtract all of this country's vehicles + + "[all][helicopter]" - add all helicopters + "[-all][helicopter]" - subtract all helicopters + "[all][plane]" - add all planes + "[-all][plane]" - subtract all planes + "[all][ship]" - add all ships + "[-all][ship]" - subtract all ships + "[all][vehicle]" - add all vehicles + "[-all][vehicle]" - subtract all vehicles + + "[blue][helicopter]" - add all blue coalition helicopters + "[-blue][helicopter]" - subtract all blue coalition helicopters + "[blue][plane]" - add all blue coalition planes + "[-blue][plane]" - subtract all blue coalition planes + "[blue][ship]" - add all blue coalition ships + "[-blue][ship]" - subtract all blue coalition ships + "[blue][vehicle]" - add all blue coalition vehicles + "[-blue][vehicle]" - subtract all blue coalition vehicles + + "[red][helicopter]" - add all red coalition helicopters + "[-red][helicopter]" - subtract all red coalition helicopters + "[red][plane]" - add all red coalition planes + "[-red][plane]" - subtract all red coalition planes + "[red][ship]" - add all red coalition ships + "[-red][ship]" - subtract all red coalition ships + "[red][vehicle]" - add all red coalition vehicles + "[-red][vehicle]" - subtract all red coalition vehicles + + Country names to be used in [c] and [-c] short-cuts: + Turkey + Norway + The Netherlands + Spain + 11 + UK + Denmark + USA + Georgia + Germany + Belgium + Canada + France + Israel + Ukraine + Russia + South Ossetia + Abkhazia + Italy + Australia + Austria + Belarus + Bulgaria + Czech Republic + China + Croatia + Finland + Greece + Hungary + India + Iran + Iraq + Japan + Kazakhstan + North Korea + Pakistan + Poland + Romania + Saudi Arabia + Serbia, Slovakia + South Korea + Sweden + Switzerland + Syria + USAF Aggressors + + Do NOT use a '[u]' notation for single units. Single units are referenced + the same way as before: Simply input their names as strings. + + These unit tables are evaluated in order, and you cannot subtract a unit + from a table before it is added. For example: + + {'[blue]', '[-c]Georgia'} + + will evaluate to all of blue coalition except those units owned by the + country named "Georgia"; however: + + {'[-c]Georgia', '[blue]'} + + will evaluate to all of the units in blue coalition, because the addition + of all units owned by blue coalition occurred AFTER the subtraction of all + units owned by Georgia (which actually subtracted nothing at all, since + there were no units in the table when the subtraction occurred). + + More examples: + + {'[blue][plane]', '[-c]Georgia', '[-g]Hawg 1'} + + Evaluates to all blue planes, except those blue units owned by the country + named "Georgia" and the units in the group named "Hawg1". + + + {'[g]arty1', '[g]arty2', '[-u]arty1_AD', '[-u]arty2_AD', 'Shark 11' } + + Evaluates to the unit named "Shark 11", plus all the units in groups named + "arty1" and "arty2" except those that are named "arty1\_AD" and "arty2\_AD". + + @table UnitNameTable + ]] + + --- Returns a table containing unit names. + -- @tparam table tbl sequential strings + -- @treturn table @{UnitNameTable} + function mist.makeUnitTable(tbl) + --Assumption: will be passed a table of strings, sequential + log:info(tbl) + local units_by_name = {} + + local l_munits = mist.DBs.units --local reference for faster execution + for i = 1, #tbl do + local unit = tbl[i] + if unit:sub(1,4) == '[-u]' then --subtract a unit + if units_by_name[unit:sub(5)] then -- 5 to end + units_by_name[unit:sub(5)] = nil --remove + end + elseif unit:sub(1,3) == '[g]' then -- add a group + for coa, coa_tbl in pairs(l_munits) do + for country, country_table in pairs(coa_tbl) do + for unit_type, unit_type_tbl in pairs(country_table) do + if type(unit_type_tbl) == 'table' then + for group_ind, group_tbl in pairs(unit_type_tbl) do + if type(group_tbl) == 'table' and group_tbl.groupName == unit:sub(4) then + -- index 4 to end + for unit_ind, unit in pairs(group_tbl.units) do + units_by_name[unit.unitName] = true --add + end + end + end + end + end + end + end + elseif unit:sub(1,4) == '[-g]' then -- subtract a group + for coa, coa_tbl in pairs(l_munits) do + for country, country_table in pairs(coa_tbl) do + for unit_type, unit_type_tbl in pairs(country_table) do + if type(unit_type_tbl) == 'table' then + for group_ind, group_tbl in pairs(unit_type_tbl) do + if type(group_tbl) == 'table' and group_tbl.groupName == unit:sub(5) then + -- index 5 to end + for unit_ind, unit in pairs(group_tbl.units) do + if units_by_name[unit.unitName] then + units_by_name[unit.unitName] = nil --remove + end + end + end + end + end + end + end + end + elseif unit:sub(1,3) == '[c]' then -- add a country + local category = '' + local country_start = 4 + if unit:sub(4,15) == '[helicopter]' then + category = 'helicopter' + country_start = 16 + elseif unit:sub(4,10) == '[plane]' then + category = 'plane' + country_start = 11 + elseif unit:sub(4,9) == '[ship]' then + category = 'ship' + country_start = 10 + elseif unit:sub(4,12) == '[vehicle]' then + category = 'vehicle' + country_start = 13 + end + for coa, coa_tbl in pairs(l_munits) do + for country, country_table in pairs(coa_tbl) do + if country == string.lower(unit:sub(country_start)) then -- match + for unit_type, unit_type_tbl in pairs(country_table) do + if type(unit_type_tbl) == 'table' and (category == '' or unit_type == category) then + for group_ind, group_tbl in pairs(unit_type_tbl) do + if type(group_tbl) == 'table' then + for unit_ind, unit in pairs(group_tbl.units) do + units_by_name[unit.unitName] = true --add + end + end + end + end + end + end + end + end + elseif unit:sub(1,4) == '[-c]' then -- subtract a country + local category = '' + local country_start = 5 + if unit:sub(5,16) == '[helicopter]' then + category = 'helicopter' + country_start = 17 + elseif unit:sub(5,11) == '[plane]' then + category = 'plane' + country_start = 12 + elseif unit:sub(5,10) == '[ship]' then + category = 'ship' + country_start = 11 + elseif unit:sub(5,13) == '[vehicle]' then + category = 'vehicle' + country_start = 14 + end + for coa, coa_tbl in pairs(l_munits) do + for country, country_table in pairs(coa_tbl) do + if country == string.lower(unit:sub(country_start)) then -- match + for unit_type, unit_type_tbl in pairs(country_table) do + if type(unit_type_tbl) == 'table' and (category == '' or unit_type == category) then + for group_ind, group_tbl in pairs(unit_type_tbl) do + if type(group_tbl) == 'table' then + for unit_ind, unit in pairs(group_tbl.units) do + if units_by_name[unit.unitName] then + units_by_name[unit.unitName] = nil --remove + end + end + end + end + end + end + end + end + end + elseif unit:sub(1,6) == '[blue]' then -- add blue coalition + local category = '' + if unit:sub(7) == '[helicopter]' then + category = 'helicopter' + elseif unit:sub(7) == '[plane]' then + category = 'plane' + elseif unit:sub(7) == '[ship]' then + category = 'ship' + elseif unit:sub(7) == '[vehicle]' then + category = 'vehicle' + end + for coa, coa_tbl in pairs(l_munits) do + if coa == 'blue' then + for country, country_table in pairs(coa_tbl) do + for unit_type, unit_type_tbl in pairs(country_table) do + if type(unit_type_tbl) == 'table' and (category == '' or unit_type == category) then + for group_ind, group_tbl in pairs(unit_type_tbl) do + if type(group_tbl) == 'table' then + for unit_ind, unit in pairs(group_tbl.units) do + units_by_name[unit.unitName] = true --add + end + end + end + end + end + end + end + end + elseif unit:sub(1,7) == '[-blue]' then -- subtract blue coalition + local category = '' + if unit:sub(8) == '[helicopter]' then + category = 'helicopter' + elseif unit:sub(8) == '[plane]' then + category = 'plane' + elseif unit:sub(8) == '[ship]' then + category = 'ship' + elseif unit:sub(8) == '[vehicle]' then + category = 'vehicle' + end + for coa, coa_tbl in pairs(l_munits) do + if coa == 'blue' then + for country, country_table in pairs(coa_tbl) do + for unit_type, unit_type_tbl in pairs(country_table) do + if type(unit_type_tbl) == 'table' and (category == '' or unit_type == category) then + for group_ind, group_tbl in pairs(unit_type_tbl) do + if type(group_tbl) == 'table' then + for unit_ind, unit in pairs(group_tbl.units) do + if units_by_name[unit.unitName] then + units_by_name[unit.unitName] = nil --remove + end + end + end + end + end + end + end + end + end + elseif unit:sub(1,5) == '[red]' then -- add red coalition + local category = '' + if unit:sub(6) == '[helicopter]' then + category = 'helicopter' + elseif unit:sub(6) == '[plane]' then + category = 'plane' + elseif unit:sub(6) == '[ship]' then + category = 'ship' + elseif unit:sub(6) == '[vehicle]' then + category = 'vehicle' + end + for coa, coa_tbl in pairs(l_munits) do + if coa == 'red' then + for country, country_table in pairs(coa_tbl) do + for unit_type, unit_type_tbl in pairs(country_table) do + if type(unit_type_tbl) == 'table' and (category == '' or unit_type == category) then + for group_ind, group_tbl in pairs(unit_type_tbl) do + if type(group_tbl) == 'table' then + for unit_ind, unit in pairs(group_tbl.units) do + units_by_name[unit.unitName] = true --add + end + end + end + end + end + end + end + end + elseif unit:sub(1,6) == '[-red]' then -- subtract red coalition + local category = '' + if unit:sub(7) == '[helicopter]' then + category = 'helicopter' + elseif unit:sub(7) == '[plane]' then + category = 'plane' + elseif unit:sub(7) == '[ship]' then + category = 'ship' + elseif unit:sub(7) == '[vehicle]' then + category = 'vehicle' + end + for coa, coa_tbl in pairs(l_munits) do + if coa == 'red' then + for country, country_table in pairs(coa_tbl) do + for unit_type, unit_type_tbl in pairs(country_table) do + if type(unit_type_tbl) == 'table' and (category == '' or unit_type == category) then + for group_ind, group_tbl in pairs(unit_type_tbl) do + if type(group_tbl) == 'table' then + for unit_ind, unit in pairs(group_tbl.units) do + if units_by_name[unit.unitName] then + units_by_name[unit.unitName] = nil --remove + end + end + end + end + end + end + end + end + end + elseif unit:sub(1,5) == '[all]' then -- add all of a certain category (or all categories) + local category = '' + if unit:sub(6) == '[helicopter]' then + category = 'helicopter' + elseif unit:sub(6) == '[plane]' then + category = 'plane' + elseif unit:sub(6) == '[ship]' then + category = 'ship' + elseif unit:sub(6) == '[vehicle]' then + category = 'vehicle' + end + for coa, coa_tbl in pairs(l_munits) do + for country, country_table in pairs(coa_tbl) do + for unit_type, unit_type_tbl in pairs(country_table) do + if type(unit_type_tbl) == 'table' and (category == '' or unit_type == category) then + for group_ind, group_tbl in pairs(unit_type_tbl) do + if type(group_tbl) == 'table' then + for unit_ind, unit in pairs(group_tbl.units) do + units_by_name[unit.unitName] = true --add + end + end + end + end + end + end + end + elseif unit:sub(1,6) == '[-all]' then -- subtract all of a certain category (or all categories) + local category = '' + if unit:sub(7) == '[helicopter]' then + category = 'helicopter' + elseif unit:sub(7) == '[plane]' then + category = 'plane' + elseif unit:sub(7) == '[ship]' then + category = 'ship' + elseif unit:sub(7) == '[vehicle]' then + category = 'vehicle' + end + for coa, coa_tbl in pairs(l_munits) do + for country, country_table in pairs(coa_tbl) do + for unit_type, unit_type_tbl in pairs(country_table) do + if type(unit_type_tbl) == 'table' and (category == '' or unit_type == category) then + for group_ind, group_tbl in pairs(unit_type_tbl) do + if type(group_tbl) == 'table' then + for unit_ind, unit in pairs(group_tbl.units) do + if units_by_name[unit.unitName] then + units_by_name[unit.unitName] = nil --remove + end + end + end + end + end + end + end + end + else -- just a regular unit + units_by_name[unit] = true --add + end + end + + local units_tbl = {} -- indexed sequentially + for unit_name, val in pairs(units_by_name) do + if val then + units_tbl[#units_tbl + 1] = unit_name -- add all the units to the table + end + end + + + units_tbl.processed = timer.getTime() --add the processed flag + return units_tbl +end + +function mist.getDeadMapObjsInZones(zone_names) + -- zone_names: table of zone names + -- returns: table of dead map objects (indexed numerically) + local map_objs = {} + local zones = {} + for i = 1, #zone_names do + if mist.DBs.zonesByName[zone_names[i]] then + zones[#zones + 1] = mist.DBs.zonesByName[zone_names[i]] + end + end + for obj_id, obj in pairs(mist.DBs.deadObjects) do + if obj.objectType and obj.objectType == 'building' then --dead map object + for i = 1, #zones do + if ((zones[i].point.x - obj.objectPos.x)^2 + (zones[i].point.z - obj.objectPos.z)^2)^0.5 <= zones[i].radius then + map_objs[#map_objs + 1] = mist.utils.deepCopy(obj) + end + end + end + end + return map_objs +end + +function mist.getDeadMapObjsInPolygonZone(zone) + -- zone_names: table of zone names + -- returns: table of dead map objects (indexed numerically) + local map_objs = {} + for obj_id, obj in pairs(mist.DBs.deadObjects) do + if obj.objectType and obj.objectType == 'building' then --dead map object + if mist.pointInPolygon(obj.objectPos, zone) then + map_objs[#map_objs + 1] = mist.utils.deepCopy(obj) + end + end + end + return map_objs +end + +function mist.pointInPolygon(point, poly, maxalt) --raycasting point in polygon. Code from http://softsurfer.com/Archive/algorithm_0103/algorithm_0103.htm + --[[local type_tbl = { + point = {'table'}, + poly = {'table'}, + maxalt = {'number', 'nil'}, + } + + local err, errmsg = mist.utils.typeCheck('mist.pointInPolygon', type_tbl, {point, poly, maxalt}) + assert(err, errmsg) + ]] + point = mist.utils.makeVec3(point) + local px = point.x + local pz = point.z + local cn = 0 + local newpoly = mist.utils.deepCopy(poly) + + if not maxalt or (point.y <= maxalt) then + local polysize = #newpoly + newpoly[#newpoly + 1] = newpoly[1] + + newpoly[1] = mist.utils.makeVec3(newpoly[1]) + + for k = 1, polysize do + newpoly[k+1] = mist.utils.makeVec3(newpoly[k+1]) + if ((newpoly[k].z <= pz) and (newpoly[k+1].z > pz)) or ((newpoly[k].z > pz) and (newpoly[k+1].z <= pz)) then + local vt = (pz - newpoly[k].z) / (newpoly[k+1].z - newpoly[k].z) + if (px < newpoly[k].x + vt*(newpoly[k+1].x - newpoly[k].x)) then + cn = cn + 1 + end + end + end + + return cn%2 == 1 + else + return false + end +end + +function mist.getUnitsInPolygon(unit_names, polyZone, max_alt) + local units = {} + + for i = 1, #unit_names do + units[#units + 1] = Unit.getByName(unitNames[i]) + end + + local inZoneUnits = {} + for i =1, #units do + if units[i]:isActive() and mist.pointInPolygon(units[i]:getPosition().p, polyZone, max_alt) then + inZoneUnits[inZoneUnits + 1] = units[i] + end + end + + return inZoneUnits +end + +function mist.getUnitsInZones(unit_names, zone_names, zone_type) + + zone_type = zone_type or 'cylinder' + if zone_type == 'c' or zone_type == 'cylindrical' or zone_type == 'C' then + zone_type = 'cylinder' + end + if zone_type == 's' or zone_type == 'spherical' or zone_type == 'S' then + zone_type = 'sphere' + end + + assert(zone_type == 'cylinder' or zone_type == 'sphere', 'invalid zone_type: ' .. tostring(zone_type)) + + local units = {} + local zones = {} + + for k = 1, #unit_names do + local unit = Unit.getByName(unit_names[k]) + if unit then + units[#units + 1] = unit + end + end + + + for k = 1, #zone_names do + local zone = trigger.misc.getZone(zone_names[k]) + if zone then + zones[#zones + 1] = {radius = zone.radius, x = zone.point.x, y = zone.point.y, z = zone.point.z} + end + end + + local in_zone_units = {} + + for units_ind = 1, #units do + for zones_ind = 1, #zones do + if zone_type == 'sphere' then --add land height value for sphere zone type + local alt = land.getHeight({x = zones[zones_ind].x, y = zones[zones_ind].z}) + if alt then + zones[zones_ind].y = alt + end + end + local unit_pos = units[units_ind]:getPosition().p + if unit_pos and units[units_ind]:isActive() == true then + if zone_type == 'cylinder' and (((unit_pos.x - zones[zones_ind].x)^2 + (unit_pos.z - zones[zones_ind].z)^2)^0.5 <= zones[zones_ind].radius) then + in_zone_units[#in_zone_units + 1] = units[units_ind] + break + elseif zone_type == 'sphere' and (((unit_pos.x - zones[zones_ind].x)^2 + (unit_pos.y - zones[zones_ind].y)^2 + (unit_pos.z - zones[zones_ind].z)^2)^0.5 <= zones[zones_ind].radius) then + in_zone_units[#in_zone_units + 1] = units[units_ind] + break + end + end + end + end + return in_zone_units +end + +function mist.getUnitsInMovingZones(unit_names, zone_unit_names, radius, zone_type) + + zone_type = zone_type or 'cylinder' + if zone_type == 'c' or zone_type == 'cylindrical' or zone_type == 'C' then + zone_type = 'cylinder' + end + if zone_type == 's' or zone_type == 'spherical' or zone_type == 'S' then + zone_type = 'sphere' + end + + assert(zone_type == 'cylinder' or zone_type == 'sphere', 'invalid zone_type: ' .. tostring(zone_type)) + + local units = {} + local zone_units = {} + + for k = 1, #unit_names do + local unit = Unit.getByName(unit_names[k]) + if unit then + units[#units + 1] = unit + end + end + + for k = 1, #zone_unit_names do + local unit = Unit.getByName(zone_unit_names[k]) + if unit then + zone_units[#zone_units + 1] = unit + end + end + + local in_zone_units = {} + + for units_ind = 1, #units do + for zone_units_ind = 1, #zone_units do + local unit_pos = units[units_ind]:getPosition().p + local zone_unit_pos = zone_units[zone_units_ind]:getPosition().p + if unit_pos and zone_unit_pos and units[units_ind]:isActive() == true then + if zone_type == 'cylinder' and (((unit_pos.x - zone_unit_pos.x)^2 + (unit_pos.z - zone_unit_pos.z)^2)^0.5 <= radius) then + in_zone_units[#in_zone_units + 1] = units[units_ind] + break + elseif zone_type == 'sphere' and (((unit_pos.x - zone_unit_pos.x)^2 + (unit_pos.y - zone_unit_pos.y)^2 + (unit_pos.z - zone_unit_pos.z)^2)^0.5 <= radius) then + in_zone_units[#in_zone_units + 1] = units[units_ind] + break + end + end + end + end + return in_zone_units +end + +function mist.getUnitsLOS(unitset1, altoffset1, unitset2, altoffset2, radius) + log:info("$1, $2, $3, $4, $5", unitset1, altoffset1, unitset2, altoffset2, radius) + radius = radius or math.huge + local unit_info1 = {} + local unit_info2 = {} + + -- get the positions all in one step, saves execution time. + for unitset1_ind = 1, #unitset1 do + local unit1 = Unit.getByName(unitset1[unitset1_ind]) + if unit1 and unit1:isActive() == true then + unit_info1[#unit_info1 + 1] = {} + unit_info1[#unit_info1].unit = unit1 + unit_info1[#unit_info1].pos = unit1:getPosition().p + end + end + + for unitset2_ind = 1, #unitset2 do + local unit2 = Unit.getByName(unitset2[unitset2_ind]) + if unit2 and unit2:isActive() == true then + unit_info2[#unit_info2 + 1] = {} + unit_info2[#unit_info2].unit = unit2 + unit_info2[#unit_info2].pos = unit2:getPosition().p + end + end + + local LOS_data = {} + -- now compute los + for unit1_ind = 1, #unit_info1 do + local unit_added = false + for unit2_ind = 1, #unit_info2 do + if radius == math.huge or (mist.vec.mag(mist.vec.sub(unit_info1[unit1_ind].pos, unit_info2[unit2_ind].pos)) < radius) then -- inside radius + local point1 = { x = unit_info1[unit1_ind].pos.x, y = unit_info1[unit1_ind].pos.y + altoffset1, z = unit_info1[unit1_ind].pos.z} + local point2 = { x = unit_info2[unit2_ind].pos.x, y = unit_info2[unit2_ind].pos.y + altoffset2, z = unit_info2[unit2_ind].pos.z} + if land.isVisible(point1, point2) then + if unit_added == false then + unit_added = true + LOS_data[#LOS_data + 1] = {} + LOS_data[#LOS_data].unit = unit_info1[unit1_ind].unit + LOS_data[#LOS_data].vis = {} + LOS_data[#LOS_data].vis[#LOS_data[#LOS_data].vis + 1] = unit_info2[unit2_ind].unit + else + LOS_data[#LOS_data].vis[#LOS_data[#LOS_data].vis + 1] = unit_info2[unit2_ind].unit + end + end + end + end + end + + return LOS_data +end + +function mist.getAvgPoint(points) + local avgX, avgY, avgZ, totNum = 0, 0, 0, 0 + for i = 1, #points do + local nPoint = mist.utils.makeVec3(points[i]) + if nPoint.z then + avgX = avgX + nPoint.x + avgY = avgY + nPoint.y + avgZ = avgZ + nPoint.z + totNum = totNum + 1 + end + end + if totNum ~= 0 then + return {x = avgX/totNum, y = avgY/totNum, z = avgZ/totNum} + end +end + +--Gets the average position of a group of units (by name) +function mist.getAvgPos(unitNames) + local avgX, avgY, avgZ, totNum = 0, 0, 0, 0 + for i = 1, #unitNames do + local unit + if Unit.getByName(unitNames[i]) then + unit = Unit.getByName(unitNames[i]) + elseif StaticObject.getByName(unitNames[i]) then + unit = StaticObject.getByName(unitNames[i]) + end + if unit then + local pos = unit:getPosition().p + if pos then -- you never know O.o + avgX = avgX + pos.x + avgY = avgY + pos.y + avgZ = avgZ + pos.z + totNum = totNum + 1 + end + end + end + if totNum ~= 0 then + return {x = avgX/totNum, y = avgY/totNum, z = avgZ/totNum} + end +end + +function mist.getAvgGroupPos(groupName) + if type(groupName) == 'string' and Group.getByName(groupName) and Group.getByName(groupName):isExist() == true then + groupName = Group.getByName(groupName) + end + local units = {} + for i = 1, #groupName:getSize() do + table.insert(units, groupName.getUnit(i):getName()) + end + + return mist.getAvgPos(units) + +end + +--[[ vars for mist.getMGRSString: +vars.units - table of unit names (NOT unitNameTable- maybe this should change). +vars.acc - integer between 0 and 5, inclusive +]] +function mist.getMGRSString(vars) + local units = vars.units + local acc = vars.acc or 5 + local avgPos = mist.getAvgPos(units) + if avgPos then + return mist.tostringMGRS(coord.LLtoMGRS(coord.LOtoLL(avgPos)), acc) + end +end + +--[[ vars for mist.getLLString +vars.units - table of unit names (NOT unitNameTable- maybe this should change). +vars.acc - integer, number of numbers after decimal place +vars.DMS - if true, output in degrees, minutes, seconds. Otherwise, output in degrees, minutes. +]] +function mist.getLLString(vars) + local units = vars.units + local acc = vars.acc or 3 + local DMS = vars.DMS + local avgPos = mist.getAvgPos(units) + if avgPos then + local lat, lon = coord.LOtoLL(avgPos) + return mist.tostringLL(lat, lon, acc, DMS) + end +end + +--[[ +vars.units- table of unit names (NOT unitNameTable- maybe this should change). +vars.ref - vec3 ref point, maybe overload for vec2 as well? +vars.alt - boolean, if used, includes altitude in string +vars.metric - boolean, gives distance in km instead of NM. +]] +function mist.getBRString(vars) + local units = vars.units + local ref = mist.utils.makeVec3(vars.ref, 0) -- turn it into Vec3 if it is not already. + local alt = vars.alt + local metric = vars.metric + local avgPos = mist.getAvgPos(units) + if avgPos then + local vec = {x = avgPos.x - ref.x, y = avgPos.y - ref.y, z = avgPos.z - ref.z} + local dir = mist.utils.getDir(vec, ref) + local dist = mist.utils.get2DDist(avgPos, ref) + if alt then + alt = avgPos.y + end + return mist.tostringBR(dir, dist, alt, metric) + end +end + +-- Returns the Vec3 coordinates of the average position of the concentration of units most in the heading direction. +--[[ vars for mist.getLeadingPos: +vars.units - table of unit names +vars.heading - direction +vars.radius - number +vars.headingDegrees - boolean, switches heading to degrees +]] +function mist.getLeadingPos(vars) + local units = vars.units + local heading = vars.heading + local radius = vars.radius + if vars.headingDegrees then + heading = mist.utils.toRadian(vars.headingDegrees) + end + + local unitPosTbl = {} + for i = 1, #units do + local unit = Unit.getByName(units[i]) + if unit and unit:isExist() then + unitPosTbl[#unitPosTbl + 1] = unit:getPosition().p + end + end + if #unitPosTbl > 0 then -- one more more units found. + -- first, find the unit most in the heading direction + local maxPos = -math.huge + + local maxPosInd -- maxPos - the furthest in direction defined by heading; maxPosInd = + for i = 1, #unitPosTbl do + local rotatedVec2 = mist.vec.rotateVec2(mist.utils.makeVec2(unitPosTbl[i]), heading) + if (not maxPos) or maxPos < rotatedVec2.x then + maxPos = rotatedVec2.x + maxPosInd = i + end + end + + --now, get all the units around this unit... + local avgPos + if radius then + local maxUnitPos = unitPosTbl[maxPosInd] + local avgx, avgy, avgz, totNum = 0, 0, 0, 0 + for i = 1, #unitPosTbl do + if mist.utils.get2DDist(maxUnitPos, unitPosTbl[i]) <= radius then + avgx = avgx + unitPosTbl[i].x + avgy = avgy + unitPosTbl[i].y + avgz = avgz + unitPosTbl[i].z + totNum = totNum + 1 + end + end + avgPos = { x = avgx/totNum, y = avgy/totNum, z = avgz/totNum} + else + avgPos = unitPosTbl[maxPosInd] + end + + return avgPos + end +end + +--[[ vars for mist.getLeadingMGRSString: +vars.units - table of unit names +vars.heading - direction +vars.radius - number +vars.headingDegrees - boolean, switches heading to degrees +vars.acc - number, 0 to 5. +]] +function mist.getLeadingMGRSString(vars) + local pos = mist.getLeadingPos(vars) + if pos then + local acc = vars.acc or 5 + return mist.tostringMGRS(coord.LLtoMGRS(coord.LOtoLL(pos)), acc) + end +end + +--[[ vars for mist.getLeadingLLString: +vars.units - table of unit names +vars.heading - direction, number +vars.radius - number +vars.headingDegrees - boolean, switches heading to degrees +vars.acc - number of digits after decimal point (can be negative) +vars.DMS - boolean, true if you want DMS. +]] +function mist.getLeadingLLString(vars) + local pos = mist.getLeadingPos(vars) + if pos then + local acc = vars.acc or 3 + local DMS = vars.DMS + local lat, lon = coord.LOtoLL(pos) + return mist.tostringLL(lat, lon, acc, DMS) + end +end + +--[[ vars for mist.getLeadingBRString: +vars.units - table of unit names +vars.heading - direction, number +vars.radius - number +vars.headingDegrees - boolean, switches heading to degrees +vars.metric - boolean, if true, use km instead of NM. +vars.alt - boolean, if true, include altitude. +vars.ref - vec3/vec2 reference point. +]] +function mist.getLeadingBRString(vars) + local pos = mist.getLeadingPos(vars) + if pos then + local ref = vars.ref + local alt = vars.alt + local metric = vars.metric + + local vec = {x = pos.x - ref.x, y = pos.y - ref.y, z = pos.z - ref.z} + local dir = mist.utils.getDir(vec, ref) + local dist = mist.utils.get2DDist(pos, ref) + if alt then + alt = pos.y + end + return mist.tostringBR(dir, dist, alt, metric) + end +end + +end + +--- Group functions. +-- @section groups +do -- group functions scope + + --- Check table used for group creation. + -- @tparam table groupData table to check. + -- @treturn boolean true if a group can be spawned using + -- this table, false otherwise. + function mist.groupTableCheck(groupData) + -- return false if country, category + -- or units are missing + if not groupData.country or + not groupData.category or + not groupData.units then + return false + end + -- return false if unitData misses + -- x, y or type + for unitId, unitData in pairs(groupData.units) do + if not unitData.x or + not unitData.y or + not unitData.type then + return false + end + end + -- everything we need is here return true + return true + end + + --- Returns group data table of give group. + function mist.getCurrentGroupData(gpName) + local dbData = mist.getGroupData(gpName) + + if Group.getByName(gpName) and Group.getByName(gpName):isExist() == true then + local newGroup = Group.getByName(gpName) + local newData = {} + newData.name = gpName + newData.groupId = tonumber(newGroup:getID()) + newData.category = newGroup:getCategory() + newData.groupName = gpName + newData.hidden = dbData.hidden + + if newData.category == 2 then + newData.category = 'vehicle' + elseif newData.category == 3 then + newData.category = 'ship' + end + + newData.units = {} + local newUnits = newGroup:getUnits() + for unitNum, unitData in pairs(newGroup:getUnits()) do + newData.units[unitNum] = {} + local uName = unitData:getName() + + if mist.DBs.unitsByName[uName] and unitData:getTypeName() == mist.DBs.unitsByName[uName].type and mist.DBs.unitsByName[uName].unitId == tonumber(unitData:getID()) then -- If old data matches most of new data + newData.units[unitNum] = mist.utils.deepCopy(mist.DBs.unitsByName[uName]) + else + newData.units[unitNum].unitId = tonumber(unitData:getID()) + newData.units[unitNum].type = unitData:getTypeName() + newData.units[unitNum].skill = mist.getUnitSkill(uName) + newData.country = string.lower(country.name[unitData:getCountry()]) + newData.units[unitNum].callsign = unitData:getCallsign() + newData.units[unitNum].unitName = uName + end + + newData.units[unitNum].x = unitData:getPosition().p.x + newData.units[unitNum].y = unitData:getPosition().p.z + newData.units[unitNum].point = {x = newData.units[unitNum].x, y = newData.units[unitNum].y} + newData.units[unitNum].heading = mist.getHeading(unitData, true) -- added to DBs + newData.units[unitNum].alt = unitData:getPosition().p.y + newData.units[unitNum].speed = mist.vec.mag(unitData:getVelocity()) + + end + + return newData + elseif StaticObject.getByName(gpName) and StaticObject.getByName(gpName):isExist() == true then + local staticObj = StaticObject.getByName(gpName) + dbData.units[1].x = staticObj:getPosition().p.x + dbData.units[1].y = staticObj:getPosition().p.z + dbData.units[1].alt = staticObj:getPosition().p.y + dbData.units[1].heading = mist.getHeading(staticObj, true) + + return dbData + end + + end + + function mist.getGroupData(gpName) + local found = false + local newData = {} + if mist.DBs.groupsByName[gpName] then + newData = mist.utils.deepCopy(mist.DBs.groupsByName[gpName]) + found = true + end + + if found == false then + for groupName, groupData in pairs(mist.DBs.groupsByName) do + if mist.stringMatch(groupName, gpName) == true then + newData = mist.utils.deepCopy(groupData) + newData.groupName = groupName + found = true + break + end + end + end + + local payloads + if newData.category == 'plane' or newData.category == 'helicopter' then + payloads = mist.getGroupPayload(newData.groupName) + end + if found == true then + --newData.hidden = false -- maybe add this to DBs + + for unitNum, unitData in pairs(newData.units) do + newData.units[unitNum] = {} + + newData.units[unitNum].unitId = unitData.unitId + --newData.units[unitNum].point = unitData.point + newData.units[unitNum].x = unitData.point.x + newData.units[unitNum].y = unitData.point.y + newData.units[unitNum].alt = unitData.alt + newData.units[unitNum].alt_type = unitData.alt_type + newData.units[unitNum].speed = unitData.speed + newData.units[unitNum].type = unitData.type + newData.units[unitNum].skill = unitData.skill + newData.units[unitNum].unitName = unitData.unitName + newData.units[unitNum].heading = unitData.heading -- added to DBs + newData.units[unitNum].playerCanDrive = unitData.playerCanDrive -- added to DBs + + + if newData.category == 'plane' or newData.category == 'helicopter' then + newData.units[unitNum].payload = payloads[unitNum] + newData.units[unitNum].livery_id = unitData.livery_id + newData.units[unitNum].onboard_num = unitData.onboard_num + newData.units[unitNum].callsign = unitData.callsign + newData.units[unitNum].AddPropAircraft = unitData.AddPropAircraft + end + if newData.category == 'static' then + newData.units[unitNum].categoryStatic = unitData.categoryStatic + newData.units[unitNum].mass = unitData.mass + newData.units[unitNum].canCargo = unitData.canCargo + newData.units[unitNum].shape_name = unitData.shape_name + end + end + --log:info(newData) + return newData + else + log:error('$1 not found in MIST database', gpName) + return + end + end + + function mist.getPayload(unitIdent) + -- refactor to search by groupId and allow groupId and groupName as inputs + local unitId = unitIdent + if type(unitIdent) == 'string' and not tonumber(unitIdent) then + if mist.DBs.MEunitsByName[unitIdent] then + unitId = mist.DBs.MEunitsByName[unitIdent].unitId + else + log:error("Unit not found in mist.DBs.MEunitsByName: $1", unitIdent) + end + end + local gpId = mist.DBs.MEunitsById[unitId].groupId + + if gpId and unitId then + for coa_name, coa_data in pairs(env.mission.coalition) do + if (coa_name == 'red' or coa_name == 'blue') and type(coa_data) == 'table' then + if coa_data.country then --there is a country table + for cntry_id, cntry_data in pairs(coa_data.country) do + for obj_type_name, obj_type_data in pairs(cntry_data) do + if obj_type_name == "helicopter" or obj_type_name == "ship" or obj_type_name == "plane" or obj_type_name == "vehicle" then -- only these types have points + if ((type(obj_type_data) == 'table') and obj_type_data.group and (type(obj_type_data.group) == 'table') and (#obj_type_data.group > 0)) then --there's a group! + for group_num, group_data in pairs(obj_type_data.group) do + if group_data and group_data.groupId == gpId then + for unitIndex, unitData in pairs(group_data.units) do --group index + if unitData.unitId == unitId then + return unitData.payload + end + end + end + end + end + end + end + end + end + end + end + else + log:error('Need string or number. Got: $1', type(unitIdent)) + return false + end + log:warn("Couldn't find payload for unit: $1", unitIdent) + return + end + + function mist.getGroupPayload(groupIdent) + local gpId = groupIdent + if type(groupIdent) == 'string' and not tonumber(groupIdent) then + if mist.DBs.MEgroupsByName[groupIdent] then + gpId = mist.DBs.MEgroupsByName[groupIdent].groupId + else + log:error('$1 not found in mist.DBs.MEgroupsByName', groupIdent) + end + end + + if gpId then + for coa_name, coa_data in pairs(env.mission.coalition) do + if (coa_name == 'red' or coa_name == 'blue') and type(coa_data) == 'table' then + if coa_data.country then --there is a country table + for cntry_id, cntry_data in pairs(coa_data.country) do + for obj_type_name, obj_type_data in pairs(cntry_data) do + if obj_type_name == "helicopter" or obj_type_name == "ship" or obj_type_name == "plane" or obj_type_name == "vehicle" then -- only these types have points + if ((type(obj_type_data) == 'table') and obj_type_data.group and (type(obj_type_data.group) == 'table') and (#obj_type_data.group > 0)) then --there's a group! + for group_num, group_data in pairs(obj_type_data.group) do + if group_data and group_data.groupId == gpId then + local payloads = {} + for unitIndex, unitData in pairs(group_data.units) do --group index + payloads[unitIndex] = unitData.payload + end + return payloads + end + end + end + end + end + end + end + end + end + else + log:error('Need string or number. Got: $1', type(groupIdent)) + return false + end + log:warn("Couldn't find payload for group: $1", groupIdent) + return + + end + + function mist.teleportToPoint(vars) -- main teleport function that all of teleport/respawn functions call + local point = vars.point + + local gpName + if vars.gpName then + gpName = vars.gpName + elseif vars.groupName then + gpName = vars.groupName + else + log:error('Missing field groupName or gpName in variable table') + end + + local action = vars.action + + local disperse = vars.disperse or false + local maxDisp = vars.maxDisp + if not vars.maxDisp then + maxDisp = 200 + else + maxDisp = vars.maxDisp + end + local radius = vars.radius or 0 + local innerRadius = vars.innerRadius + + local route = vars.route + local dbData = false + + local newGroupData + if gpName and not vars.groupData then + if string.lower(action) == 'teleport' or string.lower(action) == 'tele' then + newGroupData = mist.getCurrentGroupData(gpName) + elseif string.lower(action) == 'respawn' then + newGroupData = mist.getGroupData(gpName) + dbData = true + elseif string.lower(action) == 'clone' then + newGroupData = mist.getGroupData(gpName) + newGroupData.clone = 'order66' + dbData = true + else + action = 'tele' + newGroupData = mist.getCurrentGroupData(gpName) + end + else + action = 'tele' + newGroupData = vars.groupData + end + + --log:info('get Randomized Point') + local diff = {x = 0, y = 0} + local newCoord, origCoord + if point then + local valid = false + + local validTerrain + if string.lower(newGroupData.category) == 'ship' then + validTerrain = {'SHALLOW_WATER' , 'WATER'} + elseif string.lower(newGroupData.category) == 'vehicle' then + validTerrain = {'LAND', 'ROAD'} + else + validTerrain = {'LAND', 'ROAD', 'SHALLOW_WATER', 'WATER', 'RUNWAY'} + end + + for i = 1, 100 do + newCoord = mist.getRandPointInCircle(point, radius, innerRadius) + if mist.isTerrainValid(newCoord, validTerrain) then + origCoord = mist.utils.deepCopy(newCoord) + diff = {x = (newCoord.x - newGroupData.units[1].x), y = (newCoord.y - newGroupData.units[1].y)} + valid = true + break + end + end + if valid == false then + log:error('Point supplied in variable table is not a valid coordinate. Valid coords: $1', validTerrain) + return false + end + end + if not newGroupData.country and mist.DBs.groupsByName[newGroupData.groupName].country then + newGroupData.country = mist.DBs.groupsByName[newGroupData.groupName].country + end + if not newGroupData.category and mist.DBs.groupsByName[newGroupData.groupName].category then + newGroupData.category = mist.DBs.groupsByName[newGroupData.groupName].category + end + + for unitNum, unitData in pairs(newGroupData.units) do + if disperse then + if maxDisp and type(maxDisp) == 'number' and unitNum ~= 1 then + newCoord = mist.getRandPointInCircle(origCoord, maxDisp) + --else + --newCoord = mist.getRandPointInCircle(zone.point, zone.radius) + end + + newGroupData.units[unitNum].x = newCoord.x + newGroupData.units[unitNum].y = newCoord.y + else + newGroupData.units[unitNum].x = unitData.x + diff.x + newGroupData.units[unitNum].y = unitData.y + diff.y + end + if point then + if (newGroupData.category == 'plane' or newGroupData.category == 'helicopter') then + if point.z and point.y > 0 and point.y > land.getHeight({newGroupData.units[unitNum].x, newGroupData.units[unitNum].y}) + 10 then + newGroupData.units[unitNum].alt = point.y + else + if newGroupData.category == 'plane' then + newGroupData.units[unitNum].alt = land.getHeight({newGroupData.units[unitNum].x, newGroupData.units[unitNum].y}) + math.random(300, 9000) + else + newGroupData.units[unitNum].alt = land.getHeight({newGroupData.units[unitNum].x, newGroupData.units[unitNum].y}) + math.random(200, 3000) + end + end + end + end + end + + if newGroupData.start_time then + newGroupData.startTime = newGroupData.start_time + end + + if newGroupData.startTime and newGroupData.startTime ~= 0 and dbData == true then + local timeDif = timer.getAbsTime() - timer.getTime0() + if timeDif > newGroupData.startTime then + newGroupData.startTime = 0 + else + newGroupData.startTime = newGroupData.startTime - timeDif + end + + end + + if route then + newGroupData.route = route + end + --mist.debug.writeData(mist.utils.serialize,{'teleportToPoint', newGroupData}, 'newGroupData.lua') + if string.lower(newGroupData.category) == 'static' then + --log:info(newGroupData) + return mist.dynAddStatic(newGroupData) + end + return mist.dynAdd(newGroupData) + + end + + function mist.respawnInZone(gpName, zone, disperse, maxDisp) + + if type(gpName) == 'table' and gpName:getName() then + gpName = gpName:getName() + elseif type(gpName) == 'table' and gpName[1]:getName() then + gpName = math.random(#gpName) + else + gpName = tostring(gpName) + end + + if type(zone) == 'string' then + zone = trigger.misc.getZone(zone) + elseif type(zone) == 'table' and not zone.radius then + zone = trigger.misc.getZone(zone[math.random(1, #zone)]) + end + local vars = {} + vars.gpName = gpName + vars.action = 'respawn' + vars.point = zone.point + vars.radius = zone.radius + vars.disperse = disperse + vars.maxDisp = maxDisp + return mist.teleportToPoint(vars) + end + + function mist.cloneInZone(gpName, zone, disperse, maxDisp) + --log:info('cloneInZone') + if type(gpName) == 'table' then + gpName = gpName:getName() + else + gpName = tostring(gpName) + end + + if type(zone) == 'string' then + zone = trigger.misc.getZone(zone) + elseif type(zone) == 'table' and not zone.radius then + zone = trigger.misc.getZone(zone[math.random(1, #zone)]) + end + local vars = {} + vars.gpName = gpName + vars.action = 'clone' + vars.point = zone.point + vars.radius = zone.radius + vars.disperse = disperse + vars.maxDisp = maxDisp + --log:info('do teleport') + return mist.teleportToPoint(vars) + end + + function mist.teleportInZone(gpName, zone, disperse, maxDisp) -- groupName, zoneName or table of Zone Names, keepForm is a boolean + if type(gpName) == 'table' and gpName:getName() then + gpName = gpName:getName() + else + gpName = tostring(gpName) + end + + if type(zone) == 'string' then + zone = trigger.misc.getZone(zone) + elseif type(zone) == 'table' and not zone.radius then + zone = trigger.misc.getZone(zone[math.random(1, #zone)]) + end + + local vars = {} + vars.gpName = gpName + vars.action = 'tele' + vars.point = zone.point + vars.radius = zone.radius + vars.disperse = disperse + vars.maxDisp = maxDisp + return mist.teleportToPoint(vars) + end + + function mist.respawnGroup(gpName, task) + local vars = {} + vars.gpName = gpName + vars.action = 'respawn' + if task and type(task) ~= 'number' then + vars.route = mist.getGroupRoute(gpName, 'task') + end + local newGroup = mist.teleportToPoint(vars) + if task and type(task) == 'number' then + local newRoute = mist.getGroupRoute(gpName, 'task') + mist.scheduleFunction(mist.goRoute, {newGroup, newRoute}, timer.getTime() + task) + end + return newGroup + end + + function mist.cloneGroup(gpName, task) + local vars = {} + vars.gpName = gpName + vars.action = 'clone' + if task and type(task) ~= 'number' then + vars.route = mist.getGroupRoute(gpName, 'task') + end + local newGroup = mist.teleportToPoint(vars) + if task and type(task) == 'number' then + local newRoute = mist.getGroupRoute(gpName, 'task') + mist.scheduleFunction(mist.goRoute, {newGroup, newRoute}, timer.getTime() + task) + end + return newGroup + end + + function mist.teleportGroup(gpName, task) + local vars = {} + vars.gpName = gpName + vars.action = 'teleport' + if task and type(task) ~= 'number' then + vars.route = mist.getGroupRoute(gpName, 'task') + end + local newGroup = mist.teleportToPoint(vars) + if task and type(task) == 'number' then + local newRoute = mist.getGroupRoute(gpName, 'task') + mist.scheduleFunction(mist.goRoute, {newGroup, newRoute}, timer.getTime() + task) + end + return newGroup + end + + function mist.spawnRandomizedGroup(groupName, vars) -- need to debug + if Group.getByName(groupName) and Group.getByName(groupName):isExist() == true then + local gpData = mist.getGroupData(groupName) + gpData.units = mist.randomizeGroupOrder(gpData.units, vars) + gpData.route = mist.getGroupRoute(groupName, 'task') + + mist.dynAdd(gpData) + end + + return true + end + + function mist.randomizeNumTable(vars) + local newTable = {} + + local excludeIndex = {} + local randomTable = {} + + if vars and vars.exclude and type(vars.exclude) == 'table' then + for index, data in pairs(vars.exclude) do + excludeIndex[data] = true + end + end + + local low, hi, size + + if vars.size then + size = vars.size + end + + if vars and vars.lowerLimit and type(vars.lowerLimit) == 'number' then + low = mist.utils.round(vars.lowerLimit) + else + low = 1 + end + + if vars and vars.upperLimit and type(vars.upperLimit) == 'number' then + hi = mist.utils.round(vars.upperLimit) + else + hi = size + end + + local choices = {} + -- add to exclude list and create list of what to randomize + for i = 1, size do + if not (i >= low and i <= hi) then + + excludeIndex[i] = true + end + if not excludeIndex[i] then + table.insert(choices, i) + else + newTable[i] = i + end + end + + for ind, num in pairs(choices) do + local found = false + local x = 0 + while found == false do + x = mist.random(size) -- get random number from list + local addNew = true + for index, _ in pairs(excludeIndex) do + if index == x then + addNew = false + break + end + end + if addNew == true then + excludeIndex[x] = true + found = true + end + excludeIndex[x] = true + + end + newTable[num] = x + end + --[[ + for i = 1, #newTable do + log:info(newTable[i]) + end + ]] + return newTable + end + + function mist.randomizeGroupOrder(passedUnits, vars) + -- figure out what to exclude, and send data to other func + local units = passedUnits + + if passedUnits.units then + units = passUnits.units + end + + local exclude = {} + local excludeNum = {} + if vars and vars.excludeType and type(vars.excludeType) == 'table' then + exclude = vars.excludeType + end + + if vars and vars.excludeNum and type(vars.excludeNum) == 'table' then + excludeNum = vars.excludeNum + end + + local low, hi + + if vars and vars.lowerLimit and type(vars.lowerLimit) == 'number' then + low = mist.utils.round(vars.lowerLimit) + else + low = 1 + end + + if vars and vars.upperLimit and type(vars.upperLimit) == 'number' then + hi = mist.utils.round(vars.upperLimit) + else + hi = #units + end + + + local excludeNum = {} + for unitIndex, unitData in pairs(units) do + if unitIndex >= low and unitIndex <= hi then -- if within range + local found = false + if #exclude > 0 then + for excludeType, index in pairs(exclude) do -- check if excluded + if mist.stringMatch(excludeType, unitData.type) then -- if excluded + excludeNum[unitIndex] = unitIndex + found = true + end + end + end + else -- unitIndex is either to low, or to high: added to exclude list + excludeNum[unitIndex] = unitId + end + end + + local newGroup = {} + local newOrder = mist.randomizeNumTable({exclude = excludeNum, size = #units}) + + for unitIndex, unitData in pairs(units) do + for i = 1, #newOrder do + if newOrder[i] == unitIndex then + newGroup[i] = mist.utils.deepCopy(units[i]) -- gets all of the unit data + newGroup[i].type = mist.utils.deepCopy(unitData.type) + newGroup[i].skill = mist.utils.deepCopy(unitData.skill) + newGroup[i].unitName = mist.utils.deepCopy(unitData.unitName) + newGroup[i].unitIndex = mist.utils.deepCopy(unitData.unitIndex) -- replaces the units data with a new type + end + end + end + return newGroup + end + + function mist.random(firstNum, secondNum) -- no support for decimals + local lowNum, highNum + if not secondNum then + highNum = firstNum + lowNum = 1 + else + lowNum = firstNum + highNum = secondNum + end + local total = 1 + if math.abs(highNum - lowNum + 1) < 50 then -- if total values is less than 50 + total = math.modf(50/math.abs(highNum - lowNum + 1)) -- make x copies required to be above 50 + end + local choices = {} + for i = 1, total do -- iterate required number of times + for x = lowNum, highNum do -- iterate between the range + choices[#choices +1] = x -- add each entry to a table + end + end + local rtnVal = math.random(#choices) -- will now do a math.random of at least 50 choices + for i = 1, 10 do + rtnVal = math.random(#choices) -- iterate a few times for giggles + end + return choices[rtnVal] + end + + function mist.stringMatch(s1, s2, bool) + local exclude = {'%-', '%(', '%)', '%_', '%[', '%]', '%.', '%#', '% ', '%{', '%}', '%$', '%%', '%?', '%+', '%^'} + if type(s1) == 'string' and type(s2) == 'string' then + for i , str in pairs(exclude) do + s1 = string.gsub(s1, str, '') + s2 = string.gsub(s2, str, '') + end + if not bool then + s1 = string.lower(s1) + s2 = string.lower(s2) + end + log:info('Comparing: $1 and $2', s1, s2) + if s1 == s2 then + return true + else + return false + end + else + log:error('Either the first or second variable were not a string') + return false + end + end + + mist.matchString = mist.stringMatch -- both commands work because order out type of I + + --[[ scope: +{ + units = {...}, -- unit names. + coa = {...}, -- coa names + countries = {...}, -- country names + CA = {...}, -- looks just like coa. + unitTypes = { red = {}, blue = {}, all = {}, Russia = {},} +} + + +scope examples: + +{ units = { 'Hawg11', 'Hawg12' }, CA = {'blue'} } + +{ countries = {'Georgia'}, unitTypes = {blue = {'A-10C', 'A-10A'}}} + +{ coa = {'all'}} + +{unitTypes = { blue = {'A-10C'}}} +]] +end + +--- Utility functions. +-- E.g. conversions between units etc. +-- @section mist.utils +do -- mist.util scope + mist.utils = {} + + --- Converts angle in radians to degrees. + -- @param angle angle in radians + -- @return angle in degrees + function mist.utils.toDegree(angle) + return angle*180/math.pi + end + + --- Converts angle in degrees to radians. + -- @param angle angle in degrees + -- @return angle in degrees + function mist.utils.toRadian(angle) + return angle*math.pi/180 + end + + --- Converts meters to nautical miles. + -- @param meters distance in meters + -- @return distance in nautical miles + function mist.utils.metersToNM(meters) + return meters/1852 + end + + --- Converts meters to feet. + -- @param meters distance in meters + -- @return distance in feet + function mist.utils.metersToFeet(meters) + return meters/0.3048 + end + + --- Converts nautical miles to meters. + -- @param nm distance in nautical miles + -- @return distance in meters + function mist.utils.NMToMeters(nm) + return nm*1852 + end + + --- Converts feet to meters. + -- @param feet distance in feet + -- @return distance in meters + function mist.utils.feetToMeters(feet) + return feet*0.3048 + end + + --- Converts meters per second to knots. + -- @param mps speed in m/s + -- @return speed in knots + function mist.utils.mpsToKnots(mps) + return mps*3600/1852 + end + + --- Converts meters per second to kilometers per hour. + -- @param mps speed in m/s + -- @return speed in km/h + function mist.utils.mpsToKmph(mps) + return mps*3.6 + end + + --- Converts knots to meters per second. + -- @param knots speed in knots + -- @return speed in m/s + function mist.utils.knotsToMps(knots) + return knots*1852/3600 + end + + --- Converts kilometers per hour to meters per second. + -- @param kmph speed in km/h + -- @return speed in m/s + function mist.utils.kmphToMps(kmph) + return kmph/3.6 + end + + --- Converts a Vec3 to a Vec2. + -- @tparam Vec3 vec the 3D vector + -- @return vector converted to Vec2 + function mist.utils.makeVec2(vec) + if vec.z then + return {x = vec.x, y = vec.z} + else + return {x = vec.x, y = vec.y} -- it was actually already vec2. + end + end + + --- Converts a Vec2 to a Vec3. + -- @tparam Vec2 vec the 2D vector + -- @param y optional new y axis (altitude) value. If omitted it's 0. + function mist.utils.makeVec3(vec, y) + if not vec.z then + if vec.alt and not y then + y = vec.alt + elseif not y then + y = 0 + end + return {x = vec.x, y = y, z = vec.y} + else + return {x = vec.x, y = vec.y, z = vec.z} -- it was already Vec3, actually. + end + end + + --- Converts a Vec2 to a Vec3 using ground level as altitude. + -- The ground level at the specific point is used as altitude (y-axis) + -- for the new vector. Optionally a offset can be specified. + -- @tparam Vec2 vec the 2D vector + -- @param[opt] offset offset to be applied to the ground level + -- @return new 3D vector + function mist.utils.makeVec3GL(vec, offset) + local adj = offset or 0 + + if not vec.z then + return {x = vec.x, y = (land.getHeight(vec) + adj), z = vec.y} + else + return {x = vec.x, y = (land.getHeight({x = vec.x, y = vec.z}) + adj), z = vec.z} + end + end + + --- Returns the center of a zone as Vec3. + -- @tparam string|table zone trigger zone name or table + -- @treturn Vec3 center of the zone + function mist.utils.zoneToVec3(zone) + local new = {} + if type(zone) == 'table' then + if zone.point then + new.x = zone.point.x + new.y = zone.point.y + new.z = zone.point.z + elseif zone.x and zone.y and zone.z then + return zone + end + return new + elseif type(zone) == 'string' then + zone = trigger.misc.getZone(zone) + if zone then + new.x = zone.point.x + new.y = zone.point.y + new.z = zone.point.z + return new + end + end + end + + --- Returns heading-error corrected direction. + -- True-north corrected direction from point along vector vec. + -- @tparam Vec3 vec + -- @tparam Vec2 point + -- @return heading-error corrected direction from point. + function mist.utils.getDir(vec, point) + local dir = math.atan2(vec.z, vec.x) + if point then + dir = dir + mist.getNorthCorrection(point) + end + if dir < 0 then + dir = dir + 2 * math.pi -- put dir in range of 0 to 2*pi + end + return dir + end + + --- Returns distance in meters between two points. + -- @tparam Vec2|Vec3 point1 first point + -- @tparam Vec2|Vec3 point2 second point + -- @treturn number distance between given points. + function mist.utils.get2DDist(point1, point2) + point1 = mist.utils.makeVec3(point1) + point2 = mist.utils.makeVec3(point2) + return mist.vec.mag({x = point1.x - point2.x, y = 0, z = point1.z - point2.z}) + end + + --- Returns distance in meters between two points in 3D space. + -- @tparam Vec3 point1 first point + -- @tparam Vec3 point2 second point + -- @treturn number distancen between given points in 3D space. + function mist.utils.get3DDist(point1, point2) + return mist.vec.mag({x = point1.x - point2.x, y = point1.y - point2.y, z = point1.z - point2.z}) + end + + --- Creates a waypoint from a vector. + -- @tparam Vec2|Vec3 vec position of the new waypoint + -- @treturn Waypoint a new waypoint to be used inside paths. + function mist.utils.vecToWP(vec) + local newWP = {} + newWP.x = vec.x + newWP.y = vec.y + if vec.z then + newWP.alt = vec.y + newWP.y = vec.z + else + newWP.alt = land.getHeight({x = vec.x, y = vec.y}) + end + return newWP + end + + --- Creates a waypoint from a unit. + -- This function also considers the units speed. + -- The alt_type of this waypoint is set to "BARO". + -- @tparam Unit pUnit Unit whose position and speed will be used. + -- @treturn Waypoint new waypoint. + function mist.utils.unitToWP(pUnit) + local unit = mist.utils.deepCopy(pUnit) + if type(unit) == 'string' then + if Unit.getByName(unit) then + unit = Unit.getByName(unit) + end + end + if unit:isExist() == true then + local new = mist.utils.vecToWP(unit:getPosition().p) + new.speed = mist.vec.mag(unit:getVelocity()) + new.alt_type = "BARO" + + return new + end + log:error("$1 not found or doesn't exist", pUnit) + return false + end + + --- Creates a deep copy of a object. + -- Usually this object is a table. + -- See also: from http://lua-users.org/wiki/CopyTable + -- @param object object to copy + -- @return copy of object + function mist.utils.deepCopy(object) + local lookup_table = {} + local function _copy(object) + if type(object) ~= "table" then + return object + elseif lookup_table[object] then + return lookup_table[object] + end + local new_table = {} + lookup_table[object] = new_table + for index, value in pairs(object) do + new_table[_copy(index)] = _copy(value) + end + return setmetatable(new_table, getmetatable(object)) + end + return _copy(object) + end + + --- Simple rounding function. + -- From http://lua-users.org/wiki/SimpleRound + -- use negative idp for rounding ahead of decimal place, positive for rounding after decimal place + -- @tparam number num number to round + -- @param idp + function mist.utils.round(num, idp) + local mult = 10^(idp or 0) + return math.floor(num * mult + 0.5) / mult + end + + --- Rounds all numbers inside a table. + -- @tparam table tbl table in which to round numbers + -- @param idp + function mist.utils.roundTbl(tbl, idp) + for id, val in pairs(tbl) do + if type(val) == 'number' then + tbl[id] = mist.utils.round(val, idp) + end + end + return tbl + end + + --- Executes the given string. + -- borrowed from Slmod + -- @tparam string s string containing LUA code. + -- @treturn boolean true if successfully executed, false otherwise + function mist.utils.dostring(s) + local f, err = loadstring(s) + if f then + return true, f() + else + return false, err + end + end + + --- Checks a table's types. + -- This function checks a tables types against a specifically forged type table. + -- @param fname + -- @tparam table type_tbl + -- @tparam table var_tbl + -- @usage -- specifically forged type table + -- type_tbl = { + -- {'table', 'number'}, + -- 'string', + -- 'number', + -- 'number', + -- {'string','nil'}, + -- {'number', 'nil'} + -- } + -- -- my_tbl index 1 must be a table or a number; + -- -- index 2, a string; index 3, a number; + -- -- index 4, a number; index 5, either a string or nil; + -- -- and index 6, either a number or nil. + -- mist.utils.typeCheck(type_tbl, my_tb) + -- @return true if table passes the check, false otherwise. + function mist.utils.typeCheck(fname, type_tbl, var_tbl) + -- log:info('type check') + for type_key, type_val in pairs(type_tbl) do + -- log:info('type_key: $1 type_val: $2', type_key, type_val) + + --type_key can be a table of accepted keys- so try to find one that is not nil + local type_key_str = '' + local act_key = type_key -- actual key within var_tbl - necessary to use for multiple possible key variables. Initialize to type_key + if type(type_key) == 'table' then + + for i = 1, #type_key do + if i ~= 1 then + type_key_str = type_key_str .. '/' + end + type_key_str = type_key_str .. tostring(type_key[i]) + if var_tbl[type_key[i]] ~= nil then + act_key = type_key[i] -- found a non-nil entry, make act_key now this val. + end + end + else + type_key_str = tostring(type_key) + end + + local err_msg = 'Error in function ' .. fname .. ', parameter "' .. type_key_str .. '", expected: ' + local passed_check = false + + if type(type_tbl[type_key]) == 'table' then + -- log:info('err_msg, before: $1', err_msg) + for j = 1, #type_tbl[type_key] do + + if j == 1 then + err_msg = err_msg .. type_tbl[type_key][j] + else + err_msg = err_msg .. ' or ' .. type_tbl[type_key][j] + end + + if type(var_tbl[act_key]) == type_tbl[type_key][j] then + passed_check = true + end + end + -- log:info('err_msg, after: $1', err_msg) + else + -- log:info('err_msg, before: $1', err_msg) + err_msg = err_msg .. type_tbl[type_key] + -- log:info('err_msg, after: $1', err_msg) + if type(var_tbl[act_key]) == type_tbl[type_key] then + passed_check = true + end + + end + + if not passed_check then + err_msg = err_msg .. ', got ' .. type(var_tbl[act_key]) + return false, err_msg + end + end + return true + end + + --- Serializes the give variable to a string. + -- borrowed from slmod + -- @param var variable to serialize + -- @treturn string variable serialized to string + function mist.utils.basicSerialize(var) + if var == nil then + return "\"\"" + else + if ((type(var) == 'number') or + (type(var) == 'boolean') or + (type(var) == 'function') or + (type(var) == 'table') or + (type(var) == 'userdata') ) then + return tostring(var) + elseif type(var) == 'string' then + var = string.format('%q', var) + return var + end + end +end + +--- Serialize value +-- borrowed from slmod (serialize_slmod) +-- @param name +-- @param value value to serialize +-- @param level +function mist.utils.serialize(name, value, level) + --Based on ED's serialize_simple2 + local function basicSerialize(o) + if type(o) == "number" then + return tostring(o) + elseif type(o) == "boolean" then + return tostring(o) + else -- assume it is a string + return mist.utils.basicSerialize(o) + end + end + + local function serializeToTbl(name, value, level) + local var_str_tbl = {} + if level == nil then level = "" end + if level ~= "" then level = level.." " end + + table.insert(var_str_tbl, level .. name .. " = ") + + if type(value) == "number" or type(value) == "string" or type(value) == "boolean" then + table.insert(var_str_tbl, basicSerialize(value) .. ",\n") + elseif type(value) == "table" then + table.insert(var_str_tbl, "\n"..level.."{\n") + + for k,v in pairs(value) do -- serialize its fields + local key + if type(k) == "number" then + key = string.format("[%s]", k) + else + key = string.format("[%q]", k) + end + + table.insert(var_str_tbl, mist.utils.serialize(key, v, level.." ")) + + end + if level == "" then + table.insert(var_str_tbl, level.."} -- end of "..name.."\n") + + else + table.insert(var_str_tbl, level.."}, -- end of "..name.."\n") + + end + else + log:error('Cannot serialize a $1', type(value)) + end + return var_str_tbl + end + + local t_str = serializeToTbl(name, value, level) + + return table.concat(t_str) +end + +--- Serialize value supporting cycles. +-- borrowed from slmod (serialize_wcycles) +-- @param name +-- @param value value to serialize +-- @param saved +function mist.utils.serializeWithCycles(name, value, saved) + --mostly straight out of Programming in Lua + local function basicSerialize(o) + if type(o) == "number" then + return tostring(o) + elseif type(o) == "boolean" then + return tostring(o) + else -- assume it is a string + return mist.utils.basicSerialize(o) + end + end + + local t_str = {} + saved = saved or {} -- initial value + if ((type(value) == 'string') or (type(value) == 'number') or (type(value) == 'table') or (type(value) == 'boolean')) then + table.insert(t_str, name .. " = ") + if type(value) == "number" or type(value) == "string" or type(value) == "boolean" then + table.insert(t_str, basicSerialize(value) .. "\n") + else + + if saved[value] then -- value already saved? + table.insert(t_str, saved[value] .. "\n") + else + saved[value] = name -- save name for next time + table.insert(t_str, "{}\n") + for k,v in pairs(value) do -- save its fields + local fieldname = string.format("%s[%s]", name, basicSerialize(k)) + table.insert(t_str, mist.utils.serializeWithCycles(fieldname, v, saved)) + end + end + end + return table.concat(t_str) + else + return "" + end +end + +--- Serialize a table to a single line string. +-- serialization of a table all on a single line, no comments, made to replace old get_table_string function +-- borrowed from slmod +-- @tparam table tbl table to serialize. +-- @treturn string string containing serialized table +function mist.utils.oneLineSerialize(tbl) + if type(tbl) == 'table' then --function only works for tables! + + local tbl_str = {} + + tbl_str[#tbl_str + 1] = '{ ' + + for ind,val in pairs(tbl) do -- serialize its fields + if type(ind) == "number" then + tbl_str[#tbl_str + 1] = '[' + tbl_str[#tbl_str + 1] = tostring(ind) + tbl_str[#tbl_str + 1] = '] = ' + else --must be a string + tbl_str[#tbl_str + 1] = '[' + tbl_str[#tbl_str + 1] = mist.utils.basicSerialize(ind) + tbl_str[#tbl_str + 1] = '] = ' + end + + if ((type(val) == 'number') or (type(val) == 'boolean')) then + tbl_str[#tbl_str + 1] = tostring(val) + tbl_str[#tbl_str + 1] = ', ' + elseif type(val) == 'string' then + tbl_str[#tbl_str + 1] = mist.utils.basicSerialize(val) + tbl_str[#tbl_str + 1] = ', ' + elseif type(val) == 'nil' then -- won't ever happen, right? + tbl_str[#tbl_str + 1] = 'nil, ' + elseif type(val) == 'table' then + tbl_str[#tbl_str + 1] = mist.utils.oneLineSerialize(val) + tbl_str[#tbl_str + 1] = ', ' --I think this is right, I just added it + else + log:war('Unable to serialize value type $1 at index $2', mist.utils.basicSerialize(type(val)), tostring(ind)) + end + + end + tbl_str[#tbl_str + 1] = '}' + return table.concat(tbl_str) + end +end + +--- Returns table in a easy readable string representation. +-- this function is not meant for serialization because it uses +-- newlines for better readability. +-- @param tbl table to show +-- @param loc +-- @param indent +-- @param tableshow_tbls +-- @return human readable string representation of given table +function mist.utils.tableShow(tbl, loc, indent, tableshow_tbls) --based on serialize_slmod, this is a _G serialization + tableshow_tbls = tableshow_tbls or {} --create table of tables + loc = loc or "" + indent = indent or "" + if type(tbl) == 'table' then --function only works for tables! + tableshow_tbls[tbl] = loc + + local tbl_str = {} + + tbl_str[#tbl_str + 1] = indent .. '{\n' + + for ind,val in pairs(tbl) do -- serialize its fields + if type(ind) == "number" then + tbl_str[#tbl_str + 1] = indent + tbl_str[#tbl_str + 1] = loc .. '[' + tbl_str[#tbl_str + 1] = tostring(ind) + tbl_str[#tbl_str + 1] = '] = ' + else + tbl_str[#tbl_str + 1] = indent + tbl_str[#tbl_str + 1] = loc .. '[' + tbl_str[#tbl_str + 1] = mist.utils.basicSerialize(ind) + tbl_str[#tbl_str + 1] = '] = ' + end + + if ((type(val) == 'number') or (type(val) == 'boolean')) then + tbl_str[#tbl_str + 1] = tostring(val) + tbl_str[#tbl_str + 1] = ',\n' + elseif type(val) == 'string' then + tbl_str[#tbl_str + 1] = mist.utils.basicSerialize(val) + tbl_str[#tbl_str + 1] = ',\n' + elseif type(val) == 'nil' then -- won't ever happen, right? + tbl_str[#tbl_str + 1] = 'nil,\n' + elseif type(val) == 'table' then + if tableshow_tbls[val] then + tbl_str[#tbl_str + 1] = tostring(val) .. ' already defined: ' .. tableshow_tbls[val] .. ',\n' + else + tableshow_tbls[val] = loc .. '[' .. mist.utils.basicSerialize(ind) .. ']' + tbl_str[#tbl_str + 1] = tostring(val) .. ' ' + tbl_str[#tbl_str + 1] = mist.utils.tableShow(val, loc .. '[' .. mist.utils.basicSerialize(ind).. ']', indent .. ' ', tableshow_tbls) + tbl_str[#tbl_str + 1] = ',\n' + end + elseif type(val) == 'function' then + if debug and debug.getinfo then + local fcnname = tostring(val) + local info = debug.getinfo(val, "S") + if info.what == "C" then + tbl_str[#tbl_str + 1] = string.format('%q', fcnname .. ', C function') .. ',\n' + else + if (string.sub(info.source, 1, 2) == [[./]]) then + tbl_str[#tbl_str + 1] = string.format('%q', fcnname .. ', defined in (' .. info.linedefined .. '-' .. info.lastlinedefined .. ')' .. info.source) ..',\n' + else + tbl_str[#tbl_str + 1] = string.format('%q', fcnname .. ', defined in (' .. info.linedefined .. '-' .. info.lastlinedefined .. ')') ..',\n' + end + end + + else + tbl_str[#tbl_str + 1] = 'a function,\n' + end + else + tbl_str[#tbl_str + 1] = 'unable to serialize value type ' .. mist.utils.basicSerialize(type(val)) .. ' at index ' .. tostring(ind) + end + end + + tbl_str[#tbl_str + 1] = indent .. '}' + return table.concat(tbl_str) + end +end +end + +--- Debug functions +-- @section mist.debug +do -- mist.debug scope + mist.debug = {} + + --- Dumps the global table _G. + -- This dumps the global table _G to a file in + -- the DCS\Logs directory. + -- This function requires you to disable script sanitization + -- in $DCS_ROOT\Scripts\MissionScripting.lua to access lfs and io + -- libraries. + -- @param fname + function mist.debug.dump_G(fname) + if lfs and io then + local fdir = lfs.writedir() .. [[Logs\]] .. fname + local f = io.open(fdir, 'w') + f:write(mist.utils.tableShow(_G)) + f:close() + log:info('Wrote debug data to $1', fdir) + --trigger.action.outText(errmsg, 10) + else + log:alert('insufficient libraries to run mist.debug.dump_G, you must disable the sanitization of the io and lfs libraries in ./Scripts/MissionScripting.lua') + --trigger.action.outText(errmsg, 10) + end + end + + --- Write debug data to file. + -- This function requires you to disable script sanitization + -- in $DCS_ROOT\Scripts\MissionScripting.lua to access lfs and io + -- libraries. + -- @param fcn + -- @param fcnVars + -- @param fname + function mist.debug.writeData(fcn, fcnVars, fname) + if lfs and io then + local fdir = lfs.writedir() .. [[Logs\]] .. fname + local f = io.open(fdir, 'w') + f:write(fcn(unpack(fcnVars, 1, table.maxn(fcnVars)))) + f:close() + log:info('Wrote debug data to $1', fdir) + local errmsg = 'mist.debug.writeData wrote data to ' .. fdir + trigger.action.outText(errmsg, 10) + else + local errmsg = 'Error: insufficient libraries to run mist.debug.writeData, you must disable the sanitization of the io and lfs libraries in ./Scripts/MissionScripting.lua' + log:alert(errmsg) + trigger.action.outText(errmsg, 10) + end + end + + --- Write mist databases to file. + -- This function requires you to disable script sanitization + -- in $DCS_ROOT\Scripts\MissionScripting.lua to access lfs and io + -- libraries. + function mist.debug.dumpDBs() + for DBname, DB in pairs(mist.DBs) do + if type(DB) == 'table' and type(DBname) == 'string' then + mist.debug.writeData(mist.utils.serialize, {DBname, DB}, 'mist_DBs_' .. DBname .. '.lua') + end + end + end +end + +--- 3D Vector functions +-- @section mist.vec +do -- mist.vec scope + mist.vec = {} + + --- Vector addition. + -- @tparam Vec3 vec1 first vector + -- @tparam Vec3 vec2 second vector + -- @treturn Vec3 new vector, sum of vec1 and vec2. + function mist.vec.add(vec1, vec2) + return {x = vec1.x + vec2.x, y = vec1.y + vec2.y, z = vec1.z + vec2.z} + end + + --- Vector substraction. + -- @tparam Vec3 vec1 first vector + -- @tparam Vec3 vec2 second vector + -- @treturn Vec3 new vector, vec2 substracted from vec1. + function mist.vec.sub(vec1, vec2) + return {x = vec1.x - vec2.x, y = vec1.y - vec2.y, z = vec1.z - vec2.z} + end + + --- Vector scalar multiplication. + -- @tparam Vec3 vec vector to multiply + -- @tparam number mult scalar multiplicator + -- @treturn Vec3 new vector multiplied with the given scalar + function mist.vec.scalarMult(vec, mult) + return {x = vec.x*mult, y = vec.y*mult, z = vec.z*mult} + end + + mist.vec.scalar_mult = mist.vec.scalarMult + + --- Vector dot product. + -- @tparam Vec3 vec1 first vector + -- @tparam Vec3 vec2 second vector + -- @treturn number dot product of given vectors + function mist.vec.dp (vec1, vec2) + return vec1.x*vec2.x + vec1.y*vec2.y + vec1.z*vec2.z + end + + --- Vector cross product. + -- @tparam Vec3 vec1 first vector + -- @tparam Vec3 vec2 second vector + -- @treturn Vec3 new vector, cross product of vec1 and vec2. + function mist.vec.cp(vec1, vec2) + return { x = vec1.y*vec2.z - vec1.z*vec2.y, y = vec1.z*vec2.x - vec1.x*vec2.z, z = vec1.x*vec2.y - vec1.y*vec2.x} + end + + --- Vector magnitude + -- @tparam Vec3 vec vector + -- @treturn number magnitude of vector vec + function mist.vec.mag(vec) + return (vec.x^2 + vec.y^2 + vec.z^2)^0.5 + end + + --- Unit vector + -- @tparam Vec3 vec + -- @treturn Vec3 unit vector of vec + function mist.vec.getUnitVec(vec) + local mag = mist.vec.mag(vec) + return { x = vec.x/mag, y = vec.y/mag, z = vec.z/mag } + end + + --- Rotate vector. + -- @tparam Vec2 vec2 to rotoate + -- @tparam number theta + -- @return Vec2 rotated vector. + function mist.vec.rotateVec2(vec2, theta) + return { x = vec2.x*math.cos(theta) - vec2.y*math.sin(theta), y = vec2.x*math.sin(theta) + vec2.y*math.cos(theta)} + end +end + +--- Flag functions. +-- The mist "Flag functions" are functions that are similar to Slmod functions +-- that detect a game condition and set a flag when that game condition is met. +-- +-- They are intended to be used by persons with little or no experience in Lua +-- programming, but with a good knowledge of the DCS mission editor. +-- @section mist.flagFunc +do -- mist.flagFunc scope + mist.flagFunc = {} + + --- Sets a flag if map objects are destroyed inside a zone. + -- Once this function is run, it will start a continuously evaluated process + -- that will set a flag true if map objects (such as bridges, buildings in + -- town, etc.) die (or have died) in a mission editor zone (or set of zones). + -- This will only happen once; once the flag is set true, the process ends. + -- @usage + -- -- Example vars table + -- vars = { + -- zones = { "zone1", "zone2" }, -- can also be a single string + -- flag = 3, -- number of the flag + -- stopflag = 4, -- optional number of the stop flag + -- req_num = 10, -- optional minimum amount of map objects needed to die + -- } + -- mist.flagFuncs.mapobjs_dead_zones(vars) + -- @tparam table vars table containing parameters. + function mist.flagFunc.mapobjs_dead_zones(vars) + --[[vars needs to be: +zones = table or string, +flag = number, +stopflag = number or nil, +req_num = number or nil + +AND used by function, +initial_number + +]] + -- type_tbl + local type_tbl = { + [{'zones', 'zone'}] = {'table', 'string'}, + flag = {'number', 'string'}, + [{'stopflag', 'stopFlag'}] = {'number', 'string', 'nil'}, + [{'req_num', 'reqnum'}] = {'number', 'nil'}, + } + + local err, errmsg = mist.utils.typeCheck('mist.flagFunc.mapobjs_dead_zones', type_tbl, vars) + assert(err, errmsg) + local zones = vars.zones or vars.zone + local flag = vars.flag + local stopflag = vars.stopflag or vars.stopFlag or -1 + local req_num = vars.req_num or vars.reqnum or 1 + local initial_number = vars.initial_number + + if type(zones) == 'string' then + zones = {zones} + end + + if not initial_number then + initial_number = #mist.getDeadMapObjsInZones(zones) + end + + if stopflag == -1 or (type(trigger.misc.getUserFlag(stopflag)) == 'number' and trigger.misc.getUserFlag(stopflag) == 0) or (type(trigger.misc.getUserFlag(stopflag)) == 'boolean' and trigger.misc.getUserFlag(stopflag) == false) then + if (#mist.getDeadMapObjsInZones(zones) - initial_number) >= req_num and trigger.misc.getUserFlag(flag) == 0 then + trigger.action.setUserFlag(flag, true) + return + else + mist.scheduleFunction(mist.flagFunc.mapobjs_dead_zones, {{zones = zones, flag = flag, stopflag = stopflag, req_num = req_num, initial_number = initial_number}}, timer.getTime() + 1) + end + end + end + + --- Sets a flag if map objects are destroyed inside a polygon. + -- Once this function is run, it will start a continuously evaluated process + -- that will set a flag true if map objects (such as bridges, buildings in + -- town, etc.) die (or have died) in a polygon. + -- This will only happen once; once the flag is set true, the process ends. + -- @usage + -- -- Example vars table + -- vars = { + -- zone = { + -- [1] = mist.DBs.unitsByName['NE corner'].point, + -- [2] = mist.DBs.unitsByName['SE corner'].point, + -- [3] = mist.DBs.unitsByName['SW corner'].point, + -- [4] = mist.DBs.unitsByName['NW corner'].point + -- } + -- flag = 3, -- number of the flag + -- stopflag = 4, -- optional number of the stop flag + -- req_num = 10, -- optional minimum amount of map objects needed to die + -- } + -- mist.flagFuncs.mapobjs_dead_zones(vars) + -- @tparam table vars table containing parameters. + function mist.flagFunc.mapobjs_dead_polygon(vars) + --[[vars needs to be: +zone = table, +flag = number, +stopflag = number or nil, +req_num = number or nil + +AND used by function, +initial_number + +]] + -- type_tbl + local type_tbl = { + [{'zone', 'polyzone'}] = 'table', + flag = {'number', 'string'}, + [{'stopflag', 'stopFlag'}] = {'number', 'string', 'nil'}, + [{'req_num', 'reqnum'}] = {'number', 'nil'}, + } + + local err, errmsg = mist.utils.typeCheck('mist.flagFunc.mapobjs_dead_polygon', type_tbl, vars) + assert(err, errmsg) + local zone = vars.zone or vars.polyzone + local flag = vars.flag + local stopflag = vars.stopflag or vars.stopFlag or -1 + local req_num = vars.req_num or vars.reqnum or 1 + local initial_number = vars.initial_number + + if not initial_number then + initial_number = #mist.getDeadMapObjsInPolygonZone(zone) + end + + if stopflag == -1 or (type(trigger.misc.getUserFlag(stopflag)) == 'number' and trigger.misc.getUserFlag(stopflag) == 0) or (type(trigger.misc.getUserFlag(stopflag)) == 'boolean' and trigger.misc.getUserFlag(stopflag) == false) then + if (#mist.getDeadMapObjsInPolygonZone(zone) - initial_number) >= req_num and trigger.misc.getUserFlag(flag) == 0 then + trigger.action.setUserFlag(flag, true) + return + else + mist.scheduleFunction(mist.flagFunc.mapobjs_dead_polygon, {{zone = zone, flag = flag, stopflag = stopflag, req_num = req_num, initial_number = initial_number}}, timer.getTime() + 1) + end + end + end + + --- Sets a flag if unit(s) is/are inside a polygon. + -- @tparam table vars @{unitsInPolygonVars} + -- @usage -- set flag 11 to true as soon as any blue vehicles + -- -- are inside the polygon shape created off of the waypoints + -- -- of the group forest1 + -- mist.flagFunc.units_in_polygon { + -- units = {'[blue][vehicle]'}, + -- zone = mist.getGroupPoints('forest1'), + -- flag = 11 + -- } + function mist.flagFunc.units_in_polygon(vars) + --[[vars needs to be: +units = table, +zone = table, +flag = number, +stopflag = number or nil, +maxalt = number or nil, +interval = number or nil, +req_num = number or nil +toggle = boolean or nil +unitTableDef = table or nil +]] + -- type_tbl + local type_tbl = { + [{'units', 'unit'}] = 'table', + [{'zone', 'polyzone'}] = 'table', + flag = {'number', 'string'}, + [{'stopflag', 'stopFlag'}] = {'number', 'string', 'nil'}, + [{'maxalt', 'alt'}] = {'number', 'nil'}, + interval = {'number', 'nil'}, + [{'req_num', 'reqnum'}] = {'number', 'nil'}, + toggle = {'boolean', 'nil'}, + unitTableDef = {'table', 'nil'}, + } + + local err, errmsg = mist.utils.typeCheck('mist.flagFunc.units_in_polygon', type_tbl, vars) + assert(err, errmsg) + local units = vars.units or vars.unit + local zone = vars.zone or vars.polyzone + local flag = vars.flag + local stopflag = vars.stopflag or vars.stopFlag or -1 + local interval = vars.interval or 1 + local maxalt = vars.maxalt or vars.alt + local req_num = vars.req_num or vars.reqnum or 1 + local toggle = vars.toggle or nil + local unitTableDef = vars.unitTableDef + + if not units.processed then + unitTableDef = mist.utils.deepCopy(units) + end + + if (units.processed and units.processed < mist.getLastDBUpdateTime()) or not units.processed then -- run unit table short cuts + if unitTableDef then + units = mist.makeUnitTable(unitTableDef) + end + end + + if stopflag == -1 or (type(trigger.misc.getUserFlag(stopflag)) == 'number' and trigger.misc.getUserFlag(stopflag) == 0) or (type(trigger.misc.getUserFlag(stopflag)) == 'boolean' and trigger.misc.getUserFlag(stopflag) == 0) then + local num_in_zone = 0 + for i = 1, #units do + local unit = Unit.getByName(units[i]) + if unit then + local pos = unit:getPosition().p + if mist.pointInPolygon(pos, zone, maxalt) then + num_in_zone = num_in_zone + 1 + if num_in_zone >= req_num and trigger.misc.getUserFlag(flag) == 0 then + trigger.action.setUserFlag(flag, true) + break + end + end + end + end + if toggle and (num_in_zone < req_num) and trigger.misc.getUserFlag(flag) > 0 then + trigger.action.setUserFlag(flag, false) + end + -- do another check in case stopflag was set true by this function + if (type(trigger.misc.getUserFlag(stopflag)) == 'number' and trigger.misc.getUserFlag(stopflag) == 0) or (type(trigger.misc.getUserFlag(stopflag)) == 'boolean' and trigger.misc.getUserFlag(stopflag) == 0) then + mist.scheduleFunction(mist.flagFunc.units_in_polygon, {{units = units, zone = zone, flag = flag, stopflag = stopflag, interval = interval, req_num = req_num, maxalt = maxalt, toggle = toggle, unitTableDef = unitTableDef}}, timer.getTime() + interval) + end + end + + end + + --- Sets a flag if unit(s) is/are inside a trigger zone. + -- @todo document + function mist.flagFunc.units_in_zones(vars) + --[[vars needs to be: + units = table, + zones = table, + flag = number, + stopflag = number or nil, + zone_type = string or nil, + req_num = number or nil, + interval = number or nil + toggle = boolean or nil + ]] + -- type_tbl + local type_tbl = { + units = 'table', + zones = 'table', + flag = {'number', 'string'}, + [{'stopflag', 'stopFlag'}] = {'number', 'string', 'nil'}, + [{'zone_type', 'zonetype'}] = {'string', 'nil'}, + [{'req_num', 'reqnum'}] = {'number', 'nil'}, + interval = {'number', 'nil'}, + toggle = {'boolean', 'nil'}, + unitTableDef = {'table', 'nil'}, + } + + local err, errmsg = mist.utils.typeCheck('mist.flagFunc.units_in_zones', type_tbl, vars) + assert(err, errmsg) + local units = vars.units + local zones = vars.zones + local flag = vars.flag + local stopflag = vars.stopflag or vars.stopFlag or -1 + local zone_type = vars.zone_type or vars.zonetype or 'cylinder' + local req_num = vars.req_num or vars.reqnum or 1 + local interval = vars.interval or 1 + local toggle = vars.toggle or nil + local unitTableDef = vars.unitTableDef + + if not units.processed then + unitTableDef = mist.utils.deepCopy(units) + end + + if (units.processed and units.processed < mist.getLastDBUpdateTime()) or not units.processed then -- run unit table short cuts + if unitTableDef then + units = mist.makeUnitTable(unitTableDef) + end + end + + if stopflag == -1 or (type(trigger.misc.getUserFlag(stopflag)) == 'number' and trigger.misc.getUserFlag(stopflag) == 0) or (type(trigger.misc.getUserFlag(stopflag)) == 'boolean' and trigger.misc.getUserFlag(stopflag) == false) then + + local in_zone_units = mist.getUnitsInZones(units, zones, zone_type) + + if #in_zone_units >= req_num and trigger.misc.getUserFlag(flag) == 0 then + trigger.action.setUserFlag(flag, true) + elseif #in_zone_units < req_num and toggle then + trigger.action.setUserFlag(flag, false) + end + -- do another check in case stopflag was set true by this function + if (type(trigger.misc.getUserFlag(stopflag)) == 'number' and trigger.misc.getUserFlag(stopflag) == 0) or (type(trigger.misc.getUserFlag(stopflag)) == 'boolean' and trigger.misc.getUserFlag(stopflag) == false) then + mist.scheduleFunction(mist.flagFunc.units_in_zones, {{units = units, zones = zones, flag = flag, stopflag = stopflag, zone_type = zone_type, req_num = req_num, interval = interval, toggle = toggle, unitTableDef = unitTableDef}}, timer.getTime() + interval) + end + end + + end + + --- Sets a flag if unit(s) is/are inside a moving zone. + -- @todo document + function mist.flagFunc.units_in_moving_zones(vars) + --[[vars needs to be: + units = table, + zone_units = table, + radius = number, + flag = number, + stopflag = number or nil, + zone_type = string or nil, + req_num = number or nil, + interval = number or nil + toggle = boolean or nil + ]] + -- type_tbl + local type_tbl = { + units = 'table', + [{'zone_units', 'zoneunits'}] = 'table', + radius = 'number', + flag = {'number', 'string'}, + [{'stopflag', 'stopFlag'}] = {'number', 'string', 'nil'}, + [{'zone_type', 'zonetype'}] = {'string', 'nil'}, + [{'req_num', 'reqnum'}] = {'number', 'nil'}, + interval = {'number', 'nil'}, + toggle = {'boolean', 'nil'}, + unitTableDef = {'table', 'nil'}, + zUnitTableDef = {'table', 'nil'}, + } + + local err, errmsg = mist.utils.typeCheck('mist.flagFunc.units_in_moving_zones', type_tbl, vars) + assert(err, errmsg) + local units = vars.units + local zone_units = vars.zone_units or vars.zoneunits + local radius = vars.radius + local flag = vars.flag + local stopflag = vars.stopflag or vars.stopFlag or -1 + local zone_type = vars.zone_type or vars.zonetype or 'cylinder' + local req_num = vars.req_num or vars.reqnum or 1 + local interval = vars.interval or 1 + local toggle = vars.toggle or nil + local unitTableDef = vars.unitTableDef + local zUnitTableDef = vars.zUnitTableDef + + if not units.processed then + unitTableDef = mist.utils.deepCopy(units) + end + + if not zone_units.processed then + zUnitTableDef = mist.utils.deepCopy(zone_units) + end + + if (units.processed and units.processed < mist.getLastDBUpdateTime()) or not units.processed then -- run unit table short cuts + if unitTableDef then + units = mist.makeUnitTable(unitTableDef) + end + end + + if (zone_units.processed and zone_units.processed < mist.getLastDBUpdateTime()) or not zone_units.processed then -- run unit table short cuts + if zUnitTableDef then + zone_units = mist.makeUnitTable(zUnitTableDef) + end + + end + + if stopflag == -1 or (type(trigger.misc.getUserFlag(stopflag)) == 'number' and trigger.misc.getUserFlag(stopflag) == 0) or (type(trigger.misc.getUserFlag(stopflag)) == 'boolean' and trigger.misc.getUserFlag(stopflag) == false) then + + local in_zone_units = mist.getUnitsInMovingZones(units, zone_units, radius, zone_type) + + if #in_zone_units >= req_num and trigger.misc.getUserFlag(flag) == 0 then + trigger.action.setUserFlag(flag, true) + elseif #in_zone_units < req_num and toggle then + trigger.action.setUserFlag(flag, false) + end + -- do another check in case stopflag was set true by this function + if (type(trigger.misc.getUserFlag(stopflag)) == 'number' and trigger.misc.getUserFlag(stopflag) == 0) or (type(trigger.misc.getUserFlag(stopflag)) == 'boolean' and trigger.misc.getUserFlag(stopflag) == false) then + mist.scheduleFunction(mist.flagFunc.units_in_moving_zones, {{units = units, zone_units = zone_units, radius = radius, flag = flag, stopflag = stopflag, zone_type = zone_type, req_num = req_num, interval = interval, toggle = toggle, unitTableDef = unitTableDef, zUnitTableDef = zUnitTableDef}}, timer.getTime() + interval) + end + end + + end + + --- Sets a flag if units have line of sight to each other. + -- @todo document + function mist.flagFunc.units_LOS(vars) + --[[vars needs to be: +unitset1 = table, +altoffset1 = number, +unitset2 = table, +altoffset2 = number, +flag = number, +stopflag = number or nil, +radius = number or nil, +interval = number or nil, +req_num = number or nil +toggle = boolean or nil +]] + -- type_tbl + local type_tbl = { + [{'unitset1', 'units1'}] = 'table', + [{'altoffset1', 'alt1'}] = 'number', + [{'unitset2', 'units2'}] = 'table', + [{'altoffset2', 'alt2'}] = 'number', + flag = {'number', 'string'}, + [{'stopflag', 'stopFlag'}] = {'number', 'string', 'nil'}, + [{'req_num', 'reqnum'}] = {'number', 'nil'}, + interval = {'number', 'nil'}, + radius = {'number', 'nil'}, + toggle = {'boolean', 'nil'}, + unitTableDef1 = {'table', 'nil'}, + unitTableDef2 = {'table', 'nil'}, + } + + local err, errmsg = mist.utils.typeCheck('mist.flagFunc.units_LOS', type_tbl, vars) + assert(err, errmsg) + local unitset1 = vars.unitset1 or vars.units1 + local altoffset1 = vars.altoffset1 or vars.alt1 + local unitset2 = vars.unitset2 or vars.units2 + local altoffset2 = vars.altoffset2 or vars.alt2 + local flag = vars.flag + local stopflag = vars.stopflag or vars.stopFlag or -1 + local interval = vars.interval or 1 + local radius = vars.radius or math.huge + local req_num = vars.req_num or vars.reqnum or 1 + local toggle = vars.toggle or nil + local unitTableDef1 = vars.unitTableDef1 + local unitTableDef2 = vars.unitTableDef2 + + if not unitset1.processed then + unitTableDef1 = mist.utils.deepCopy(unitset1) + end + + if not unitset2.processed then + unitTableDef2 = mist.utils.deepCopy(unitset2) + end + + if (unitset1.processed and unitset1.processed < mist.getLastDBUpdateTime()) or not unitset1.processed then -- run unit table short cuts + if unitTableDef1 then + unitset1 = mist.makeUnitTable(unitTableDef1) + end + end + + if (unitset2.processed and unitset2.processed < mist.getLastDBUpdateTime()) or not unitset2.processed then -- run unit table short cuts + if unitTableDef2 then + unitset2 = mist.makeUnitTable(unitTableDef2) + end + end + + + if stopflag == -1 or (type(trigger.misc.getUserFlag(stopflag)) == 'number' and trigger.misc.getUserFlag(stopflag) == 0) or (type(trigger.misc.getUserFlag(stopflag)) == 'boolean' and trigger.misc.getUserFlag(stopflag) == false) then + + local unitLOSdata = mist.getUnitsLOS(unitset1, altoffset1, unitset2, altoffset2, radius) + + if #unitLOSdata >= req_num and trigger.misc.getUserFlag(flag) == 0 then + trigger.action.setUserFlag(flag, true) + elseif #unitLOSdata < req_num and toggle then + trigger.action.setUserFlag(flag, false) + end + -- do another check in case stopflag was set true by this function + if (type(trigger.misc.getUserFlag(stopflag)) == 'number' and trigger.misc.getUserFlag(stopflag) == 0) or (type(trigger.misc.getUserFlag(stopflag)) == 'boolean' and trigger.misc.getUserFlag(stopflag) == false) then + mist.scheduleFunction(mist.flagFunc.units_LOS, {{unitset1 = unitset1, altoffset1 = altoffset1, unitset2 = unitset2, altoffset2 = altoffset2, flag = flag, stopflag = stopflag, radius = radius, req_num = req_num, interval = interval, toggle = toggle, unitTableDef1 = unitTableDef1, unitTableDef2 = unitTableDef2}}, timer.getTime() + interval) + end + end + end + + --- Sets a flag if group is alive. + -- @todo document + function mist.flagFunc.group_alive(vars) + --[[vars +groupName +flag +toggle +interval +stopFlag + +]] + local type_tbl = { + [{'group', 'groupname', 'gp', 'groupName'}] = 'string', + flag = {'number', 'string'}, + [{'stopflag', 'stopFlag'}] = {'number', 'string', 'nil'}, + interval = {'number', 'nil'}, + toggle = {'boolean', 'nil'}, + } + + local err, errmsg = mist.utils.typeCheck('mist.flagFunc.group_alive', type_tbl, vars) + assert(err, errmsg) + + local groupName = vars.groupName or vars.group or vars.gp or vars.Groupname + local flag = vars.flag + local stopflag = vars.stopflag or vars.stopFlag or -1 + local interval = vars.interval or 1 + local toggle = vars.toggle or nil + + + if stopflag == -1 or (type(trigger.misc.getUserFlag(stopflag)) == 'number' and trigger.misc.getUserFlag(stopflag) == 0) or (type(trigger.misc.getUserFlag(stopflag)) == 'boolean' and trigger.misc.getUserFlag(stopflag) == false) then + if Group.getByName(groupName) and Group.getByName(groupName):isExist() == true and #Group.getByName(groupName):getUnits() > 0 then + if trigger.misc.getUserFlag(flag) == 0 then + trigger.action.setUserFlag(flag, true) + end + else + if toggle then + trigger.action.setUserFlag(flag, false) + end + end + end + + if (type(trigger.misc.getUserFlag(stopflag)) == 'number' and trigger.misc.getUserFlag(stopflag) == 0) or (type(trigger.misc.getUserFlag(stopflag)) == 'boolean' and trigger.misc.getUserFlag(stopflag) == false) then + mist.scheduleFunction(mist.flagFunc.group_alive, {{groupName = groupName, flag = flag, stopflag = stopflag, interval = interval, toggle = toggle}}, timer.getTime() + interval) + end + + end + + --- Sets a flag if group is dead. + -- @todo document + function mist.flagFunc.group_dead(vars) + local type_tbl = { + [{'group', 'groupname', 'gp', 'groupName'}] = 'string', + flag = {'number', 'string'}, + [{'stopflag', 'stopFlag'}] = {'number', 'string', 'nil'}, + interval = {'number', 'nil'}, + toggle = {'boolean', 'nil'}, + } + + local err, errmsg = mist.utils.typeCheck('mist.flagFunc.group_dead', type_tbl, vars) + assert(err, errmsg) + + local groupName = vars.groupName or vars.group or vars.gp or vars.Groupname + local flag = vars.flag + local stopflag = vars.stopflag or vars.stopFlag or -1 + local interval = vars.interval or 1 + local toggle = vars.toggle or nil + + + if stopflag == -1 or (type(trigger.misc.getUserFlag(stopflag)) == 'number' and trigger.misc.getUserFlag(stopflag) == 0) or (type(trigger.misc.getUserFlag(stopflag)) == 'boolean' and trigger.misc.getUserFlag(stopflag) == false) then + if (Group.getByName(groupName) and Group.getByName(groupName):isExist() == false) or (Group.getByName(groupName) and #Group.getByName(groupName):getUnits() < 1) or not Group.getByName(groupName) then + if trigger.misc.getUserFlag(flag) == 0 then + trigger.action.setUserFlag(flag, true) + end + else + if toggle then + trigger.action.setUserFlag(flag, false) + end + end + end + + if (type(trigger.misc.getUserFlag(stopflag)) == 'number' and trigger.misc.getUserFlag(stopflag) == 0) or (type(trigger.misc.getUserFlag(stopflag)) == 'boolean' and trigger.misc.getUserFlag(stopflag) == false) then + mist.scheduleFunction(mist.flagFunc.group_dead, {{groupName = groupName, flag = flag, stopflag = stopflag, interval = interval, toggle = toggle}}, timer.getTime() + interval) + end + end + + --- Sets a flag if less than given percent of group is alive. + -- @todo document + function mist.flagFunc.group_alive_less_than(vars) + local type_tbl = { + [{'group', 'groupname', 'gp', 'groupName'}] = 'string', + percent = 'number', + flag = {'number', 'string'}, + [{'stopflag', 'stopFlag'}] = {'number', 'string', 'nil'}, + interval = {'number', 'nil'}, + toggle = {'boolean', 'nil'}, + } + + local err, errmsg = mist.utils.typeCheck('mist.flagFunc.group_alive_less_than', type_tbl, vars) + assert(err, errmsg) + + local groupName = vars.groupName or vars.group or vars.gp or vars.Groupname + local flag = vars.flag + local percent = vars.percent + local stopflag = vars.stopflag or vars.stopFlag or -1 + local interval = vars.interval or 1 + local toggle = vars.toggle or nil + + + if stopflag == -1 or (type(trigger.misc.getUserFlag(stopflag)) == 'number' and trigger.misc.getUserFlag(stopflag) == 0) or (type(trigger.misc.getUserFlag(stopflag)) == 'boolean' and trigger.misc.getUserFlag(stopflag) == false) then + if Group.getByName(groupName) and Group.getByName(groupName):isExist() == true then + if Group.getByName(groupName):getSize()/Group.getByName(groupName):getInitialSize() < percent/100 then + if trigger.misc.getUserFlag(flag) == 0 then + trigger.action.setUserFlag(flag, true) + end + else + if toggle then + trigger.action.setUserFlag(flag, false) + end + end + else + if trigger.misc.getUserFlag(flag) == 0 then + trigger.action.setUserFlag(flag, true) + end + end + end + + if (type(trigger.misc.getUserFlag(stopflag)) == 'number' and trigger.misc.getUserFlag(stopflag) == 0) or (type(trigger.misc.getUserFlag(stopflag)) == 'boolean' and trigger.misc.getUserFlag(stopflag) == false) then + mist.scheduleFunction(mist.flagFunc.group_alive_less_than, {{groupName = groupName, flag = flag, stopflag = stopflag, interval = interval, toggle = toggle, percent = percent}}, timer.getTime() + interval) + end + end + + --- Sets a flag if more than given percent of group is alive. + -- @todo document + function mist.flagFunc.group_alive_more_than(vars) + local type_tbl = { + [{'group', 'groupname', 'gp', 'groupName'}] = 'string', + percent = 'number', + flag = {'number', 'string'}, + [{'stopflag', 'stopFlag'}] = {'number', 'string', 'nil'}, + interval = {'number', 'nil'}, + toggle = {'boolean', 'nil'}, + } + + local err, errmsg = mist.utils.typeCheck('mist.flagFunc.group_alive_more_than', type_tbl, vars) + assert(err, errmsg) + + local groupName = vars.groupName or vars.group or vars.gp or vars.Groupname + local flag = vars.flag + local percent = vars.percent + local stopflag = vars.stopflag or vars.stopFlag or -1 + local interval = vars.interval or 1 + local toggle = vars.toggle or nil + + + if stopflag == -1 or (type(trigger.misc.getUserFlag(stopflag)) == 'number' and trigger.misc.getUserFlag(stopflag) == 0) or (type(trigger.misc.getUserFlag(stopflag)) == 'boolean' and trigger.misc.getUserFlag(stopflag) == false) then + if Group.getByName(groupName) and Group.getByName(groupName):isExist() == true then + if Group.getByName(groupName):getSize()/Group.getByName(groupName):getInitialSize() > percent/100 then + if trigger.misc.getUserFlag(flag) == 0 then + trigger.action.setUserFlag(flag, true) + end + else + if toggle and trigger.misc.getUserFlag(flag) == 1 then + trigger.action.setUserFlag(flag, false) + end + end + else --- just in case + if toggle and trigger.misc.getUserFlag(flag) == 1 then + trigger.action.setUserFlag(flag, false) + end + end + end + + if (type(trigger.misc.getUserFlag(stopflag)) == 'number' and trigger.misc.getUserFlag(stopflag) == 0) or (type(trigger.misc.getUserFlag(stopflag)) == 'boolean' and trigger.misc.getUserFlag(stopflag) == false) then + mist.scheduleFunction(mist.flagFunc.group_alive_more_than, {{groupName = groupName, flag = flag, stopflag = stopflag, interval = interval, toggle = toggle, percent = percent}}, timer.getTime() + interval) + end + end + + mist.flagFunc.mapobjsDeadPolygon = mist.flagFunc.mapobjs_dead_polygon + mist.flagFunc.mapobjsDeadZones = mist.flagFunc.Mapobjs_dead_zones + mist.flagFunc.unitsInZones = mist.flagFunc.units_in_zones + mist.flagFunc.unitsInMovingZones = mist.flagFunc.units_in_moving_zones + mist.flagFunc.unitsInPolygon = mist.flagFunc.units_in_polygon + mist.flagFunc.unitsLOS = mist.flagFunc.units_LOS + mist.flagFunc.groupAlive = mist.flagFunc.group_alive + mist.flagFunc.groupDead = mist.flagFunc.group_dead + mist.flagFunc.groupAliveMoreThan = mist.flagFunc.group_alive_more_than + mist.flagFunc.groupAliveLessThan = mist.flagFunc.group_alive_less_than + +end + +--- Message functions. +-- Messaging system +-- @section mist.msg +do -- mist.msg scope + local messageList = {} + -- this defines the max refresh rate of the message box it honestly only needs to + -- go faster than this for precision timing stuff (which could be its own function) + local messageDisplayRate = 0.1 + local messageID = 0 + local displayActive = false + local displayFuncId = 0 + + local caSlots = false + local caMSGtoGroup = false + + if env.mission.groundControl then -- just to be sure? + for index, value in pairs(env.mission.groundControl) do + if type(value) == 'table' then + for roleName, roleVal in pairs(value) do + for rIndex, rVal in pairs(roleVal) do + if rIndex == 'red' or rIndex == 'blue' then + if env.mission.groundControl[index][roleName][rIndex] > 0 then + caSlots = true + break + end + end + end + end + elseif type(value) == 'boolean' and value == true then + caSlots = true + break + end + end + end + + local function mistdisplayV5() + --[[thoughts to improve upon + event handler based activeClients table. + display messages only when there is an update + possibly co-routine it. + ]] + end + + local function mistdisplayV4() + local activeClients = {} + + for clientId, clientData in pairs(mist.DBs.humansById) do + if Unit.getByName(clientData.unitName) and Unit.getByName(clientData.unitName):isExist() == true then + activeClients[clientData.groupId] = clientData.groupName + end + end + + --[[if caSlots == true and caMSGtoGroup == true then + + end]] + + + if #messageList > 0 then + if displayActive == false then + displayActive = true + end + --mist.debug.writeData(mist.utils.serialize,{'msg', messageList}, 'messageList.lua') + local msgTableText = {} + local msgTableSound = {} + + for messageId, messageData in pairs(messageList) do + if messageData.displayedFor > messageData.displayTime then + messageData:remove() -- now using the remove/destroy function. + else + if messageData.displayedFor then + messageData.displayedFor = messageData.displayedFor + messageDisplayRate + end + local nextSound = 1000 + local soundIndex = 0 + + if messageData.multSound and #messageData.multSound > 0 then + for index, sData in pairs(messageData.multSound) do + if sData.time <= messageData.displayedFor and sData.played == false and sData.time < nextSound then -- find index of the next sound to be played + nextSound = sData.time + soundIndex = index + end + end + if soundIndex ~= 0 then + messageData.multSound[soundIndex].played = true + end + end + + for recIndex, recData in pairs(messageData.msgFor) do -- iterate recipiants + if recData == 'RED' or recData == 'BLUE' or activeClients[recData] then -- rec exists + if messageData.text then -- text + if not msgTableText[recData] then -- create table entry for text + msgTableText[recData] = {} + msgTableText[recData].text = {} + if recData == 'RED' or recData == 'BLUE' then + msgTableText[recData].text[1] = '-------Combined Arms Message-------- \n' + end + msgTableText[recData].text[#msgTableText[recData].text + 1] = messageData.text + msgTableText[recData].displayTime = messageData.displayTime - messageData.displayedFor + else -- add to table entry and adjust display time if needed + if recData == 'RED' or recData == 'BLUE' then + msgTableText[recData].text[#msgTableText[recData].text + 1] = '\n ---------------- Combined Arms Message: \n' + else + msgTableText[recData].text[#msgTableText[recData].text + 1] = '\n ---------------- \n' + end + msgTableText[recData].text[#msgTableText[recData].text + 1] = messageData.text + if msgTableText[recData].displayTime < messageData.displayTime - messageData.displayedFor then + msgTableText[recData].displayTime = messageData.displayTime - messageData.displayedFor + else + msgTableText[recData].displayTime = 1 + end + end + end + if soundIndex ~= 0 then + msgTableSound[recData] = messageData.multSound[soundIndex].file + end + end + end + + + end + end + ------- new display + + if caSlots == true and caMSGtoGroup == false then + if msgTableText.RED then + trigger.action.outTextForCoalition(coalition.side.RED, table.concat(msgTableText.RED.text), msgTableText.RED.displayTime, true) + + end + if msgTableText.BLUE then + trigger.action.outTextForCoalition(coalition.side.BLUE, table.concat(msgTableText.BLUE.text), msgTableText.BLUE.displayTime, true) + end + end + + for index, msgData in pairs(msgTableText) do + if type(index) == 'number' then -- its a groupNumber + trigger.action.outTextForGroup(index, table.concat(msgData.text), msgData.displayTime, true) + end + end + --- new audio + if msgTableSound.RED then + trigger.action.outSoundForCoalition(coalition.side.RED, msgTableSound.RED) + end + if msgTableSound.BLUE then + trigger.action.outSoundForCoalition(coalition.side.BLUE, msgTableSound.BLUE) + end + + + for index, file in pairs(msgTableSound) do + if type(index) == 'number' then -- its a groupNumber + trigger.action.outSoundForGroup(index, file) + end + end + else + mist.removeFunction(displayFuncId) + displayActive = false + end + + end + + local typeBase = { + ['Mi-8MT'] = {'Mi-8MTV2', 'Mi-8MTV', 'Mi-8'}, + ['MiG-21Bis'] = {'Mig-21'}, + ['MiG-15bis'] = {'Mig-15'}, + ['FW-190D9'] = {'FW-190'}, + ['Bf-109K-4'] = {'Bf-109'}, + } + + --[[function mist.setCAGroupMSG(val) + if type(val) == 'boolean' then + caMSGtoGroup = val + return true + end + return false +end]] + + mist.message = { + + add = function(vars) + local function msgSpamFilter(recList, spamBlockOn) + for id, name in pairs(recList) do + if name == spamBlockOn then + -- log:info('already on recList') + return recList + end + end + --log:info('add to recList') + table.insert(recList, spamBlockOn) + return recList + end + + --[[ + local vars = {} + vars.text = 'Hello World' + vars.displayTime = 20 + vars.msgFor = {coa = {'red'}, countries = {'Ukraine', 'Georgia'}, unitTypes = {'A-10C'}} + mist.message.add(vars) + + Displays the message for all red coalition players. Players belonging to Ukraine and Georgia, and all A-10Cs on the map + + ]] + + + local new = {} + new.text = vars.text -- The actual message + new.displayTime = vars.displayTime -- How long will the message appear for + new.displayedFor = 0 -- how long the message has been displayed so far + new.name = vars.name -- ID to overwrite the older message (if it exists) Basically it replaces a message that is displayed with new text. + new.addedAt = timer.getTime() + new.update = true + + if vars.multSound and vars.multSound[1] then + new.multSound = vars.multSound + else + new.multSound = {} + end + + if vars.sound or vars.fileName then -- converts old sound file system into new multSound format + local sound = vars.sound + if vars.fileName then + sound = vars.fileName + end + new.multSound[#new.multSound+1] = {time = 0.1, file = sound} + end + + if #new.multSound > 0 then + for i, data in pairs(new.multSound) do + data.played = false + end + end + + local newMsgFor = {} -- list of all groups message displays for + for forIndex, forData in pairs(vars.msgFor) do + for list, listData in pairs(forData) do + for clientId, clientData in pairs(mist.DBs.humansById) do + forIndex = string.lower(forIndex) + if type(listData) == 'string' then + listData = string.lower(listData) + end + if (forIndex == 'coa' and (listData == string.lower(clientData.coalition) or listData == 'all')) or (forIndex == 'countries' and string.lower(clientData.country) == listData) or (forIndex == 'units' and string.lower(clientData.unitName) == listData) then -- + newMsgFor = msgSpamFilter(newMsgFor, clientData.groupId) -- so units dont get the same message twice if complex rules are given + --table.insert(newMsgFor, clientId) + elseif forIndex == 'unittypes' then + for typeId, typeData in pairs(listData) do + local found = false + for clientDataEntry, clientDataVal in pairs(clientData) do + if type(clientDataVal) == 'string' then + if mist.matchString(list, clientDataVal) == true or list == 'all' then + local sString = typeData + for rName, pTbl in pairs(typeBase) do -- just a quick check to see if the user may have meant something and got the specific type of the unit wrong + for pIndex, pName in pairs(pTbl) do + if mist.stringMatch(sString, pName) then + sString = rName + end + end + end + if sString == clientData.type then + found = true + newMsgFor = msgSpamFilter(newMsgFor, clientData.groupId) -- sends info oto other function to see if client is already recieving the current message. + --table.insert(newMsgFor, clientId) + end + end + end + if found == true then -- shouldn't this be elsewhere too? + break + end + end + end + + end + end + for coaData, coaId in pairs(coalition.side) do + if string.lower(forIndex) == 'coa' or string.lower(forIndex) == 'ca' then + if listData == string.lower(coaData) or listData == 'all' then + newMsgFor = msgSpamFilter(newMsgFor, coaData) + end + end + end + end + end + + if #newMsgFor > 0 then + new.msgFor = newMsgFor -- I swear its not confusing + + else + return false + end + + + if vars.name and type(vars.name) == 'string' then + for i = 1, #messageList do + if messageList[i].name then + if messageList[i].name == vars.name then + --log:info('updateMessage') + messageList[i].displayedFor = 0 + messageList[i].addedAt = timer.getTime() + messageList[i].sound = new.sound + messageList[i].text = new.text + messageList[i].msgFor = new.msgFor + messageList[i].multSound = new.multSound + messageList[i].update = true + return messageList[i].messageID + end + end + end + end + + messageID = messageID + 1 + new.messageID = messageID + + --mist.debug.writeData(mist.utils.serialize,{'msg', new}, 'newMsg.lua') + + + messageList[#messageList + 1] = new + + local mt = { __index = mist.message} + setmetatable(new, mt) + + if displayActive == false then + displayActive = true + displayFuncId = mist.scheduleFunction(mistdisplayV4, {}, timer.getTime() + messageDisplayRate, messageDisplayRate) + end + + return messageID + + end, + + remove = function(self) -- Now a self variable; the former functionality taken up by mist.message.removeById. + for i, msgData in pairs(messageList) do + if messageList[i] == self then + table.remove(messageList, i) + return true --removal successful + end + end + return false -- removal not successful this script fails at life! + end, + + removeById = function(id) -- This function is NOT passed a self variable; it is the remove by id function. + for i, msgData in pairs(messageList) do + if messageList[i].messageID == id then + table.remove(messageList, i) + return true --removal successful + end + end + return false -- removal not successful this script fails at life! + end, + } + + --[[ vars for mist.msgMGRS +vars.units - table of unit names (NOT unitNameTable- maybe this should change). +vars.acc - integer between 0 and 5, inclusive +vars.text - text in the message +vars.displayTime - self explanatory +vars.msgFor - scope +]] + function mist.msgMGRS(vars) + local units = vars.units + local acc = vars.acc + local text = vars.text + local displayTime = vars.displayTime + local msgFor = vars.msgFor + + local s = mist.getMGRSString{units = units, acc = acc} + local newText + if text then + if string.find(text, '%%s') then -- look for %s + newText = string.format(text, s) -- insert the coordinates into the message + else + -- just append to the end. + newText = text .. s + end + else + newText = s + end + mist.message.add{ + text = newText, + displayTime = displayTime, + msgFor = msgFor + } + end + + --[[ vars for mist.msgLL +vars.units - table of unit names (NOT unitNameTable- maybe this should change) (Yes). +vars.acc - integer, number of numbers after decimal place +vars.DMS - if true, output in degrees, minutes, seconds. Otherwise, output in degrees, minutes. +vars.text - text in the message +vars.displayTime - self explanatory +vars.msgFor - scope +]] + function mist.msgLL(vars) + local units = vars.units -- technically, I don't really need to do this, but it helps readability. + local acc = vars.acc + local DMS = vars.DMS + local text = vars.text + local displayTime = vars.displayTime + local msgFor = vars.msgFor + + local s = mist.getLLString{units = units, acc = acc, DMS = DMS} + local newText + if text then + if string.find(text, '%%s') then -- look for %s + newText = string.format(text, s) -- insert the coordinates into the message + else + -- just append to the end. + newText = text .. s + end + else + newText = s + end + + mist.message.add{ + text = newText, + displayTime = displayTime, + msgFor = msgFor + } + + end + + --[[ +vars.units- table of unit names (NOT unitNameTable- maybe this should change). +vars.ref - vec3 ref point, maybe overload for vec2 as well? +vars.alt - boolean, if used, includes altitude in string +vars.metric - boolean, gives distance in km instead of NM. +vars.text - text of the message +vars.displayTime +vars.msgFor - scope +]] + function mist.msgBR(vars) + local units = vars.units -- technically, I don't really need to do this, but it helps readability. + local ref = vars.ref -- vec2/vec3 will be handled in mist.getBRString + local alt = vars.alt + local metric = vars.metric + local text = vars.text + local displayTime = vars.displayTime + local msgFor = vars.msgFor + + local s = mist.getBRString{units = units, ref = ref, alt = alt, metric = metric} + local newText + if text then + if string.find(text, '%%s') then -- look for %s + newText = string.format(text, s) -- insert the coordinates into the message + else + -- just append to the end. + newText = text .. s + end + else + newText = s + end + + mist.message.add{ + text = newText, + displayTime = displayTime, + msgFor = msgFor + } + + end + + -- basically, just sub-types of mist.msgBR... saves folks the work of getting the ref point. + --[[ +vars.units- table of unit names (NOT unitNameTable- maybe this should change). +vars.ref - string red, blue +vars.alt - boolean, if used, includes altitude in string +vars.metric - boolean, gives distance in km instead of NM. +vars.text - text of the message +vars.displayTime +vars.msgFor - scope +]] + function mist.msgBullseye(vars) + if string.lower(vars.ref) == 'red' then + vars.ref = mist.DBs.missionData.bullseye.red + mist.msgBR(vars) + elseif string.lower(vars.ref) == 'blue' then + vars.ref = mist.DBs.missionData.bullseye.blue + mist.msgBR(vars) + end + end + + --[[ +vars.units- table of unit names (NOT unitNameTable- maybe this should change). +vars.ref - unit name of reference point +vars.alt - boolean, if used, includes altitude in string +vars.metric - boolean, gives distance in km instead of NM. +vars.text - text of the message +vars.displayTime +vars.msgFor - scope +]] + function mist.msgBRA(vars) + if Unit.getByName(vars.ref) and Unit.getByName(vars.ref):isExist() == true then + vars.ref = Unit.getByName(vars.ref):getPosition().p + if not vars.alt then + vars.alt = true + end + mist.msgBR(vars) + end + end + + --[[ vars for mist.msgLeadingMGRS: +vars.units - table of unit names +vars.heading - direction +vars.radius - number +vars.headingDegrees - boolean, switches heading to degrees (optional) +vars.acc - number, 0 to 5. +vars.text - text of the message +vars.displayTime +vars.msgFor - scope +]] + function mist.msgLeadingMGRS(vars) + local units = vars.units -- technically, I don't really need to do this, but it helps readability. + local heading = vars.heading + local radius = vars.radius + local headingDegrees = vars.headingDegrees + local acc = vars.acc + local text = vars.text + local displayTime = vars.displayTime + local msgFor = vars.msgFor + + local s = mist.getLeadingMGRSString{units = units, heading = heading, radius = radius, headingDegrees = headingDegrees, acc = acc} + local newText + if text then + if string.find(text, '%%s') then -- look for %s + newText = string.format(text, s) -- insert the coordinates into the message + else + -- just append to the end. + newText = text .. s + end + else + newText = s + end + + mist.message.add{ + text = newText, + displayTime = displayTime, + msgFor = msgFor + } + + + end + + --[[ vars for mist.msgLeadingLL: +vars.units - table of unit names +vars.heading - direction, number +vars.radius - number +vars.headingDegrees - boolean, switches heading to degrees (optional) +vars.acc - number of digits after decimal point (can be negative) +vars.DMS - boolean, true if you want DMS. (optional) +vars.text - text of the message +vars.displayTime +vars.msgFor - scope +]] + function mist.msgLeadingLL(vars) + local units = vars.units -- technically, I don't really need to do this, but it helps readability. + local heading = vars.heading + local radius = vars.radius + local headingDegrees = vars.headingDegrees + local acc = vars.acc + local DMS = vars.DMS + local text = vars.text + local displayTime = vars.displayTime + local msgFor = vars.msgFor + + local s = mist.getLeadingLLString{units = units, heading = heading, radius = radius, headingDegrees = headingDegrees, acc = acc, DMS = DMS} + local newText + + if text then + if string.find(text, '%%s') then -- look for %s + newText = string.format(text, s) -- insert the coordinates into the message + else + -- just append to the end. + newText = text .. s + end + else + newText = s + end + + mist.message.add{ + text = newText, + displayTime = displayTime, + msgFor = msgFor + } + + end + + --[[ +vars.units - table of unit names +vars.heading - direction, number +vars.radius - number +vars.headingDegrees - boolean, switches heading to degrees (optional) +vars.metric - boolean, if true, use km instead of NM. (optional) +vars.alt - boolean, if true, include altitude. (optional) +vars.ref - vec3/vec2 reference point. +vars.text - text of the message +vars.displayTime +vars.msgFor - scope +]] + function mist.msgLeadingBR(vars) + local units = vars.units -- technically, I don't really need to do this, but it helps readability. + local heading = vars.heading + local radius = vars.radius + local headingDegrees = vars.headingDegrees + local metric = vars.metric + local alt = vars.alt + local ref = vars.ref -- vec2/vec3 will be handled in mist.getBRString + local text = vars.text + local displayTime = vars.displayTime + local msgFor = vars.msgFor + + local s = mist.getLeadingBRString{units = units, heading = heading, radius = radius, headingDegrees = headingDegrees, metric = metric, alt = alt, ref = ref} + local newText + + if text then + if string.find(text, '%%s') then -- look for %s + newText = string.format(text, s) -- insert the coordinates into the message + else + -- just append to the end. + newText = text .. s + end + else + newText = s + end + + mist.message.add{ + text = newText, + displayTime = displayTime, + msgFor = msgFor + } + end +end + +--- Demo functions. +-- @section mist.demos +do -- mist.demos scope + mist.demos = {} + + function mist.demos.printFlightData(unit) + if unit:isExist() then + local function printData(unit, prevVel, prevE, prevTime) + local angles = mist.getAttitude(unit) + if angles then + local Heading = angles.Heading + local Pitch = angles.Pitch + local Roll = angles.Roll + local Yaw = angles.Yaw + local AoA = angles.AoA + local ClimbAngle = angles.ClimbAngle + + if not Heading then + Heading = 'NA' + else + Heading = string.format('%12.2f', mist.utils.toDegree(Heading)) + end + + if not Pitch then + Pitch = 'NA' + else + Pitch = string.format('%12.2f', mist.utils.toDegree(Pitch)) + end + + if not Roll then + Roll = 'NA' + else + Roll = string.format('%12.2f', mist.utils.toDegree(Roll)) + end + + local AoAplusYaw = 'NA' + if AoA and Yaw then + AoAplusYaw = string.format('%12.2f', mist.utils.toDegree((AoA^2 + Yaw^2)^0.5)) + end + + if not Yaw then + Yaw = 'NA' + else + Yaw = string.format('%12.2f', mist.utils.toDegree(Yaw)) + end + + if not AoA then + AoA = 'NA' + else + AoA = string.format('%12.2f', mist.utils.toDegree(AoA)) + end + + if not ClimbAngle then + ClimbAngle = 'NA' + else + ClimbAngle = string.format('%12.2f', mist.utils.toDegree(ClimbAngle)) + end + local unitPos = unit:getPosition() + local unitVel = unit:getVelocity() + local curTime = timer.getTime() + local absVel = string.format('%12.2f', mist.vec.mag(unitVel)) + + + local unitAcc = 'NA' + local Gs = 'NA' + local axialGs = 'NA' + local transGs = 'NA' + if prevVel and prevTime then + local xAcc = (unitVel.x - prevVel.x)/(curTime - prevTime) + local yAcc = (unitVel.y - prevVel.y)/(curTime - prevTime) + local zAcc = (unitVel.z - prevVel.z)/(curTime - prevTime) + + unitAcc = string.format('%12.2f', mist.vec.mag({x = xAcc, y = yAcc, z = zAcc})) + Gs = string.format('%12.2f', mist.vec.mag({x = xAcc, y = yAcc + 9.81, z = zAcc})/9.81) + axialGs = string.format('%12.2f', mist.vec.dp({x = xAcc, y = yAcc + 9.81, z = zAcc}, unitPos.x)/9.81) + transGs = string.format('%12.2f', mist.vec.mag(mist.vec.cp({x = xAcc, y = yAcc + 9.81, z = zAcc}, unitPos.x))/9.81) + end + + local E = 0.5*mist.vec.mag(unitVel)^2 + 9.81*unitPos.p.y + + local energy = string.format('%12.2e', E) + + local dEdt = 'NA' + if prevE and prevTime then + dEdt = string.format('%12.2e', (E - prevE)/(curTime - prevTime)) + end + + trigger.action.outText(string.format('%-25s', 'Heading: ') .. Heading .. ' degrees\n' .. string.format('%-25s', 'Roll: ') .. Roll .. ' degrees\n' .. string.format('%-25s', 'Pitch: ') .. Pitch + .. ' degrees\n' .. string.format('%-25s', 'Yaw: ') .. Yaw .. ' degrees\n' .. string.format('%-25s', 'AoA: ') .. AoA .. ' degrees\n' .. string.format('%-25s', 'AoA plus Yaw: ') .. AoAplusYaw .. ' degrees\n' .. string.format('%-25s', 'Climb Angle: ') .. + ClimbAngle .. ' degrees\n' .. string.format('%-25s', 'Absolute Velocity: ') .. absVel .. ' m/s\n' .. string.format('%-25s', 'Absolute Acceleration: ') .. unitAcc ..' m/s^2\n' + .. string.format('%-25s', 'Axial G loading: ') .. axialGs .. ' g\n' .. string.format('%-25s', 'Transverse G loading: ') .. transGs .. ' g\n' .. string.format('%-25s', 'Absolute G loading: ') .. Gs .. ' g\n' .. string.format('%-25s', 'Energy: ') .. energy .. ' J/kg\n' .. string.format('%-25s', 'dE/dt: ') .. dEdt ..' J/(kg*s)', 1) + return unitVel, E, curTime + end + end + + local function frameFinder(unit, prevVel, prevE, prevTime) + if unit:isExist() then + local currVel = unit:getVelocity() + if prevVel and (prevVel.x ~= currVel.x or prevVel.y ~= currVel.y or prevVel.z ~= currVel.z) or (prevTime and (timer.getTime() - prevTime) > 0.25) then + prevVel, prevE, prevTime = printData(unit, prevVel, prevE, prevTime) + end + mist.scheduleFunction(frameFinder, {unit, prevVel, prevE, prevTime}, timer.getTime() + 0.005) -- it can't go this fast, limited to the 100 times a sec check right now. + end + end + + + local curVel = unit:getVelocity() + local curTime = timer.getTime() + local curE = 0.5*mist.vec.mag(curVel)^2 + 9.81*unit:getPosition().p.y + frameFinder(unit, curVel, curE, curTime) + + end + + end + +end + +--- Time conversion functions. +-- @section mist.time +do -- mist.time scope + mist.time = {} + -- returns a string for specified military time + -- theTime is optional + -- if present current time in mil time is returned + -- if number or table the time is converted into mil tim + function mist.time.convertToSec(timeTable) + + timeInSec = 0 + if timeTable and type(timeTable) == 'number' then + timeInSec = timeTable + elseif timeTable and type(timeTable) == 'table' and (timeTable.d or timeTable.h or timeTable.m or timeTable.s) then + if timeTable.d and type(timeTable.d) == 'number' then + timeInSec = timeInSec + (timeTable.d*86400) + end + if timeTable.h and type(timeTable.h) == 'number' then + timeInSec = timeInSec + (timeTable.h*3600) + end + if timeTable.m and type(timeTable.m) == 'number' then + timeInSec = timeInSec + (timeTable.m*60) + end + if timeTable.s and type(timeTable.s) == 'number' then + timeInSec = timeInSec + timeTable.s + end + + end + return timeInSec + end + + function mist.time.getDHMS(timeInSec) + if timeInSec and type(timeInSec) == 'number' then + local tbl = {d = 0, h = 0, m = 0, s = 0} + if timeInSec > 86400 then + while timeInSec > 86400 do + tbl.d = tbl.d + 1 + timeInSec = timeInSec - 86400 + end + end + if timeInSec > 3600 then + while timeInSec > 3600 do + tbl.h = tbl.h + 1 + timeInSec = timeInSec - 3600 + end + end + if timeInSec > 60 then + while timeInSec > 60 do + tbl.m = tbl.m + 1 + timeInSec = timeInSec - 60 + end + end + tbl.s = timeInSec + return tbl + else + log:error("Didn't recieve number") + return + end + end + + function mist.getMilString(theTime) + local timeInSec = 0 + if theTime then + timeInSec = mist.time.convertToSec(theTime) + else + timeInSec = mist.utils.round(timer.getAbsTime(), 0) + end + + local DHMS = mist.time.getDHMS(timeInSec) + + return tostring(string.format('%02d', DHMS.h) .. string.format('%02d',DHMS.m)) + end + + function mist.getClockString(theTime, hour) + local timeInSec = 0 + if theTime then + timeInSec = mist.time.convertToSec(theTime) + else + timeInSec = mist.utils.round(timer.getAbsTime(), 0) + end + local DHMS = mist.time.getDHMS(timeInSec) + if hour then + if DHMS.h > 12 then + DHMS.h = DHMS.h - 12 + return tostring(string.format('%02d', DHMS.h) .. ':' .. string.format('%02d',DHMS.m) .. ':' .. string.format('%02d',DHMS.s) .. ' PM') + else + return tostring(string.format('%02d', DHMS.h) .. ':' .. string.format('%02d',DHMS.m) .. ':' .. string.format('%02d',DHMS.s) .. ' AM') + end + else + return tostring(string.format('%02d', DHMS.h) .. ':' .. string.format('%02d',DHMS.m) .. ':' .. string.format('%02d',DHMS.s)) + end + end + + -- returns the date in string format + -- both variables optional + -- first val returns with the month as a string + -- 2nd val defins if it should be written the American way or the wrong way. + function mist.time.getDate(convert) + local cal = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31} -- + local date = {} + + if not env.mission.date then -- Not likely to happen. Resaving mission auto updates this to remove it. + date.d = 0 + date.m = 6 + date.y = 2011 + else + date.d = env.mission.date.Day + date.m = env.mission.date.Month + date.y = env.mission.date.Year + end + local start = 86400 + local timeInSec = mist.utils.round(timer.getAbsTime()) + if convert and type(convert) == 'number' then + timeInSec = convert + end + if timeInSec > 86400 then + while start < timeInSec do + if date.d >= cal[date.m] then + if date.m == 2 and date.d == 28 then -- HOLY COW we can edit years now. Gotta re-add this! + if date.y % 4 == 0 and date.y % 100 == 0 and date.y % 400 ~= 0 or date.y % 4 > 0 then + date.m = date.m + 1 + date.d = 0 + end + --date.d = 29 + else + date.m = date.m + 1 + date.d = 0 + end + end + if date.m == 13 then + date.m = 1 + date.y = date.y + 1 + end + date.d = date.d + 1 + start = start + 86400 + + end + end + return date + end + + function mist.time.relativeToStart(time) + if type(time) == 'number' then + return time - timer.getTime0() + end + end + + function mist.getDateString(rtnType, murica, oTime) -- returns date based on time + local word = {'January', 'Feburary', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December' } -- 'etc + local curTime = 0 + if oTime then + curTime = oTime + else + curTime = mist.utils.round(timer.getAbsTime()) + end + local tbl = mist.time.getDate(curTime) + + if rtnType then + if murica then + return tostring(word[tbl.m] .. ' ' .. tbl.d .. ' ' .. tbl.y) + else + return tostring(tbl.d .. ' ' .. word[tbl.m] .. ' ' .. tbl.y) + end + else + if murica then + return tostring(tbl.m .. '.' .. tbl.d .. '.' .. tbl.y) + else + return tostring(tbl.d .. '.' .. tbl.m .. '.' .. tbl.y) + end + end + end + --WIP + function mist.time.milToGame(milString, rtnType) --converts a military time. By default returns the abosolute time that event would occur. With optional value it returns how many seconds from time of call till that time. + local curTime = mist.utils.round(timer.getAbsTime()) + local milTimeInSec = 0 + + if milString and type(milString) == 'string' and string.len(milString) >= 4 then + local hr = tonumber(string.sub(milString, 1, 2)) + local mi = tonumber(string.sub(milString, 3)) + milTimeInSec = milTimeInSec + (mi*60) + (hr*3600) + elseif milString and type(milString) == 'table' and (milString.d or milString.h or milString.m or milString.s) then + milTimeInSec = mist.time.convertToSec(milString) + end + + local startTime = timer.getTime0() + local daysOffset = 0 + if startTime > 86400 then + daysOffset = mist.utils.round(startTime/86400) + if daysOffset > 0 then + milTimeInSec = milTimeInSec *daysOffset + end + end + + if curTime > milTimeInSec then + milTimeInSec = milTimeInSec + 86400 + end + if rtnType then + milTimeInSec = milTimeInSec - startTime + end + return milTimeInSec + end + + +end + +--- Group task functions. +-- @section tasks +do -- group tasks scope + mist.ground = {} + mist.fixedWing = {} + mist.heli = {} + mist.air = {} + mist.air.fixedWing = {} + mist.air.heli = {} + + --- Tasks group to follow a route. + -- This sets the mission task for the given group. + -- Any wrapped actions inside the path (like enroute + -- tasks) will be executed. + -- @tparam Group group group to task. + -- @tparam table path containing + -- points defining a route. + function mist.goRoute(group, path) + local misTask = { + id = 'Mission', + params = { + route = { + points = mist.utils.deepCopy(path), + }, + }, + } + if type(group) == 'string' then + group = Group.getByName(group) + end + if group then + local groupCon = group:getController() + if groupCon then + groupCon:setTask(misTask) + return true + end + end + return false + end + + -- same as getGroupPoints but returns speed and formation type along with vec2 of point} + function mist.getGroupRoute(groupIdent, task) + -- refactor to search by groupId and allow groupId and groupName as inputs + local gpId = groupIdent + if mist.DBs.MEgroupsByName[groupIdent] then + gpId = mist.DBs.MEgroupsByName[groupIdent].groupId + else + log:error('$1 not found in mist.DBs.MEgroupsByName', groupIdent) + end + + for coa_name, coa_data in pairs(env.mission.coalition) do + if (coa_name == 'red' or coa_name == 'blue') and type(coa_data) == 'table' then + if coa_data.country then --there is a country table + for cntry_id, cntry_data in pairs(coa_data.country) do + for obj_type_name, obj_type_data in pairs(cntry_data) do + if obj_type_name == "helicopter" or obj_type_name == "ship" or obj_type_name == "plane" or obj_type_name == "vehicle" then -- only these types have points + if ((type(obj_type_data) == 'table') and obj_type_data.group and (type(obj_type_data.group) == 'table') and (#obj_type_data.group > 0)) then --there's a group! + for group_num, group_data in pairs(obj_type_data.group) do + if group_data and group_data.groupId == gpId then -- this is the group we are looking for + if group_data.route and group_data.route.points and #group_data.route.points > 0 then + local points = {} + + for point_num, point in pairs(group_data.route.points) do + local routeData = {} + if not point.point then + routeData.x = point.x + routeData.y = point.y + else + routeData.point = point.point --it's possible that the ME could move to the point = Vec2 notation. + end + routeData.form = point.action + routeData.speed = point.speed + routeData.alt = point.alt + routeData.alt_type = point.alt_type + routeData.airdromeId = point.airdromeId + routeData.helipadId = point.helipadId + routeData.type = point.type + routeData.action = point.action + if task then + routeData.task = point.task + end + points[point_num] = routeData + end + + return points + end + log:error('Group route not defined in mission editor for groupId: $1', gpId) + return + end --if group_data and group_data.name and group_data.name == 'groupname' + end --for group_num, group_data in pairs(obj_type_data.group) do + end --if ((type(obj_type_data) == 'table') and obj_type_data.group and (type(obj_type_data.group) == 'table') and (#obj_type_data.group > 0)) then + end --if obj_type_name == "helicopter" or obj_type_name == "ship" or obj_type_name == "plane" or obj_type_name == "vehicle" or obj_type_name == "static" then + end --for obj_type_name, obj_type_data in pairs(cntry_data) do + end --for cntry_id, cntry_data in pairs(coa_data.country) do + end --if coa_data.country then --there is a country table + end --if coa_name == 'red' or coa_name == 'blue' and type(coa_data) == 'table' then + end --for coa_name, coa_data in pairs(mission.coalition) do + end + + -- function mist.ground.buildPath() end -- ???? + + function mist.ground.patrolRoute(vars) + log:info('patrol') + local tempRoute = {} + local useRoute = {} + local gpData = vars.gpData + if type(gpData) == 'string' then + gpData = Group.getByName(gpData) + end + + local useGroupRoute + if not vars.useGroupRoute then + useGroupRoute = vars.gpData + else + useGroupRoute = vars.useGroupRoute + end + local routeProvided = false + if not vars.route then + if useGroupRoute then + tempRoute = mist.getGroupRoute(useGroupRoute) + end + else + useRoute = vars.route + local posStart = mist.getLeadPos(gpData) + useRoute[1] = mist.ground.buildWP(posStart, useRoute[1].action, useRoute[1].speed) + routeProvided = true + end + + + local overRideSpeed = vars.speed or 'default' + local pType = vars.pType + local offRoadForm = vars.offRoadForm or 'default' + local onRoadForm = vars.onRoadForm or 'default' + + if routeProvided == false and #tempRoute > 0 then + local posStart = mist.getLeadPos(gpData) + + + useRoute[#useRoute + 1] = mist.ground.buildWP(posStart, offRoadForm, overRideSpeed) + for i = 1, #tempRoute do + local tempForm = tempRoute[i].action + local tempSpeed = tempRoute[i].speed + + if offRoadForm == 'default' then + tempForm = tempRoute[i].action + end + if onRoadForm == 'default' then + onRoadForm = 'On Road' + end + if (string.lower(tempRoute[i].action) == 'on road' or string.lower(tempRoute[i].action) == 'onroad' or string.lower(tempRoute[i].action) == 'on_road') then + tempForm = onRoadForm + else + tempForm = offRoadForm + end + + if type(overRideSpeed) == 'number' then + tempSpeed = overRideSpeed + end + + + useRoute[#useRoute + 1] = mist.ground.buildWP(tempRoute[i], tempForm, tempSpeed) + end + + if pType and string.lower(pType) == 'doubleback' then + local curRoute = mist.utils.deepCopy(useRoute) + for i = #curRoute, 2, -1 do + useRoute[#useRoute + 1] = mist.ground.buildWP(curRoute[i], curRoute[i].action, curRoute[i].speed) + end + end + + useRoute[1].action = useRoute[#useRoute].action -- make it so the first WP matches the last WP + end + + local cTask3 = {} + local newPatrol = {} + newPatrol.route = useRoute + newPatrol.gpData = gpData:getName() + cTask3[#cTask3 + 1] = 'mist.ground.patrolRoute(' + cTask3[#cTask3 + 1] = mist.utils.oneLineSerialize(newPatrol) + cTask3[#cTask3 + 1] = ')' + cTask3 = table.concat(cTask3) + local tempTask = { + id = 'WrappedAction', + params = { + action = { + id = 'Script', + params = { + command = cTask3, + + }, + }, + }, + } + + useRoute[#useRoute].task = tempTask + log:info(useRoute) + mist.goRoute(gpData, useRoute) + + return + end + + function mist.ground.patrol(gpData, pType, form, speed) + local vars = {} + + if type(gpData) == 'table' and gpData:getName() then + gpData = gpData:getName() + end + + vars.useGroupRoute = gpData + vars.gpData = gpData + vars.pType = pType + vars.offRoadForm = form + vars.speed = speed + + mist.ground.patrolRoute(vars) + + return + end + + -- No longer accepts path + function mist.ground.buildWP(point, overRideForm, overRideSpeed) + + local wp = {} + wp.x = point.x + + if point.z then + wp.y = point.z + else + wp.y = point.y + end + local form, speed + + if point.speed and not overRideSpeed then + wp.speed = point.speed + elseif type(overRideSpeed) == 'number' then + wp.speed = overRideSpeed + else + wp.speed = mist.utils.kmphToMps(20) + end + + if point.form and not overRideForm then + form = point.form + else + form = overRideForm + end + + if not form then + wp.action = 'Cone' + else + form = string.lower(form) + if form == 'off_road' or form == 'off road' then + wp.action = 'Off Road' + elseif form == 'on_road' or form == 'on road' then + wp.action = 'On Road' + elseif form == 'rank' or form == 'line_abrest' or form == 'line abrest' or form == 'lineabrest'then + wp.action = 'Rank' + elseif form == 'cone' then + wp.action = 'Cone' + elseif form == 'diamond' then + wp.action = 'Diamond' + elseif form == 'vee' then + wp.action = 'Vee' + elseif form == 'echelon_left' or form == 'echelon left' or form == 'echelonl' then + wp.action = 'EchelonL' + elseif form == 'echelon_right' or form == 'echelon right' or form == 'echelonr' then + wp.action = 'EchelonR' + else + wp.action = 'Cone' -- if nothing matched + end + end + + wp.type = 'Turning Point' + + return wp + + end + + function mist.fixedWing.buildWP(point, WPtype, speed, alt, altType) + + local wp = {} + wp.x = point.x + + if point.z then + wp.y = point.z + else + wp.y = point.y + end + + if alt and type(alt) == 'number' then + wp.alt = alt + else + wp.alt = 2000 + end + + if altType then + altType = string.lower(altType) + if altType == 'radio' or altType == 'agl' then + wp.alt_type = 'RADIO' + elseif altType == 'baro' or altType == 'asl' then + wp.alt_type = 'BARO' + end + else + wp.alt_type = 'RADIO' + end + + if point.speed then + speed = point.speed + end + + if point.type then + WPtype = point.type + end + + if not speed then + wp.speed = mist.utils.kmphToMps(500) + else + wp.speed = speed + end + + if not WPtype then + wp.action = 'Turning Point' + else + WPtype = string.lower(WPtype) + if WPtype == 'flyover' or WPtype == 'fly over' or WPtype == 'fly_over' then + wp.action = 'Fly Over Point' + elseif WPtype == 'turningpoint' or WPtype == 'turning point' or WPtype == 'turning_point' then + wp.action = 'Turning Point' + else + wp.action = 'Turning Point' + end + end + + wp.type = 'Turning Point' + return wp + end + + function mist.heli.buildWP(point, WPtype, speed, alt, altType) + + local wp = {} + wp.x = point.x + + if point.z then + wp.y = point.z + else + wp.y = point.y + end + + if alt and type(alt) == 'number' then + wp.alt = alt + else + wp.alt = 500 + end + + if altType then + altType = string.lower(altType) + if altType == 'radio' or altType == 'agl' then + wp.alt_type = 'RADIO' + elseif altType == 'baro' or altType == 'asl' then + wp.alt_type = 'BARO' + end + else + wp.alt_type = 'RADIO' + end + + if point.speed then + speed = point.speed + end + + if point.type then + WPtype = point.type + end + + if not speed then + wp.speed = mist.utils.kmphToMps(200) + else + wp.speed = speed + end + + if not WPtype then + wp.action = 'Turning Point' + else + WPtype = string.lower(WPtype) + if WPtype == 'flyover' or WPtype == 'fly over' or WPtype == 'fly_over' then + wp.action = 'Fly Over Point' + elseif WPtype == 'turningpoint' or WPtype == 'turning point' or WPtype == 'turning_point' then + wp.action = 'Turning Point' + else + wp.action = 'Turning Point' + end + end + + wp.type = 'Turning Point' + return wp + end + + -- need to return a Vec3 or Vec2? + function mist.getRandPointInCircle(point, radius, innerRadius) + local theta = 2*math.pi*math.random() + local rad = math.random() + math.random() + if rad > 1 then + rad = 2 - rad + end + + local radMult + if innerRadius and innerRadius <= radius then + radMult = (radius - innerRadius)*rad + innerRadius + else + radMult = radius*rad + end + + if not point.z then --might as well work with vec2/3 + point.z = point.y + end + + local rndCoord + if radius > 0 then + rndCoord = {x = math.cos(theta)*radMult + point.x, y = math.sin(theta)*radMult + point.z} + else + rndCoord = {x = point.x, y = point.z} + end + return rndCoord + end + + function mist.getRandomPointInZone(zoneName, innerRadius) + if type(zoneName) == 'string' and type(trigger.misc.getZone(zoneName)) == 'table' then + return mist.getRandPointInCircle(trigger.misc.getZone(zoneName).point, trigger.misc.getZone(zoneName).radius, innerRadius) + end + return false + end + + function mist.groupToRandomPoint(vars) + local group = vars.group --Required + local point = vars.point --required + local radius = vars.radius or 0 + local innerRadius = vars.innerRadius + local form = vars.form or 'Cone' + local heading = vars.heading or math.random()*2*math.pi + local headingDegrees = vars.headingDegrees + local speed = vars.speed or mist.utils.kmphToMps(20) + + + local useRoads + if not vars.disableRoads then + useRoads = true + else + useRoads = false + end + + local path = {} + + if headingDegrees then + heading = headingDegrees*math.pi/180 + end + + if heading >= 2*math.pi then + heading = heading - 2*math.pi + end + + local rndCoord = mist.getRandPointInCircle(point, radius, innerRadius) + + local offset = {} + local posStart = mist.getLeadPos(group) + + offset.x = mist.utils.round(math.sin(heading - (math.pi/2)) * 50 + rndCoord.x, 3) + offset.z = mist.utils.round(math.cos(heading + (math.pi/2)) * 50 + rndCoord.y, 3) + path[#path + 1] = mist.ground.buildWP(posStart, form, speed) + + + if useRoads == true and ((point.x - posStart.x)^2 + (point.z - posStart.z)^2)^0.5 > radius * 1.3 then + path[#path + 1] = mist.ground.buildWP({x = posStart.x + 11, z = posStart.z + 11}, 'off_road', speed) + path[#path + 1] = mist.ground.buildWP(posStart, 'on_road', speed) + path[#path + 1] = mist.ground.buildWP(offset, 'on_road', speed) + else + path[#path + 1] = mist.ground.buildWP({x = posStart.x + 25, z = posStart.z + 25}, form, speed) + end + + path[#path + 1] = mist.ground.buildWP(offset, form, speed) + path[#path + 1] = mist.ground.buildWP(rndCoord, form, speed) + + mist.goRoute(group, path) + + return + end + + function mist.groupRandomDistSelf(gpData, dist, form, heading, speed) + local pos = mist.getLeadPos(gpData) + local fakeZone = {} + fakeZone.radius = dist or math.random(300, 1000) + fakeZone.point = {x = pos.x, y = pos.y, z = pos.z} + mist.groupToRandomZone(gpData, fakeZone, form, heading, speed) + + return + end + + function mist.groupToRandomZone(gpData, zone, form, heading, speed) + if type(gpData) == 'string' then + gpData = Group.getByName(gpData) + end + + if type(zone) == 'string' then + zone = trigger.misc.getZone(zone) + elseif type(zone) == 'table' and not zone.radius then + zone = trigger.misc.getZone(zone[math.random(1, #zone)]) + end + + if speed then + speed = mist.utils.kmphToMps(speed) + end + + local vars = {} + vars.group = gpData + vars.radius = zone.radius + vars.form = form + vars.headingDegrees = heading + vars.speed = speed + vars.point = mist.utils.zoneToVec3(zone) + + mist.groupToRandomPoint(vars) + + return + end + + function mist.isTerrainValid(coord, terrainTypes) -- vec2/3 and enum or table of acceptable terrain types + if coord.z then + coord.y = coord.z + end + local typeConverted = {} + + if type(terrainTypes) == 'string' then -- if its a string it does this check + for constId, constData in pairs(land.SurfaceType) do + if string.lower(constId) == string.lower(terrainTypes) or string.lower(constData) == string.lower(terrainTypes) then + table.insert(typeConverted, constId) + end + end + elseif type(terrainTypes) == 'table' then -- if its a table it does this check + for typeId, typeData in pairs(terrainTypes) do + for constId, constData in pairs(land.SurfaceType) do + if string.lower(constId) == string.lower(typeData) or string.lower(constData) == string.lower(typeId) then + table.insert(typeConverted, constId) + end + end + end + end + for validIndex, validData in pairs(typeConverted) do + if land.getSurfaceType(coord) == land.SurfaceType[validData] then + return true + end + end + return false + end + + function mist.terrainHeightDiff(coord, searchSize) + local samples = {} + local searchRadius = 5 + if searchSize then + searchRadius = searchSize + end + if type(coord) == 'string' then + coord = mist.utils.zoneToVec3(coord) + end + + coord = mist.utils.makeVec2(coord) + + samples[#samples + 1] = land.getHeight(coord) + for i = 0, 360, 30 do + samples[#samples + 1] = land.getHeight({x = (coord.x + (math.sin(math.rad(i))*searchRadius)), y = (coord.y + (math.cos(math.rad(i))*searchRadius))}) + if searchRadius >= 20 then -- if search radius is sorta large, take a sample halfway between center and outer edge + samples[#samples + 1] = land.getHeight({x = (coord.x + (math.sin(math.rad(i))*(searchRadius/2))), y = (coord.y + (math.cos(math.rad(i))*(searchRadius/2)))}) + end + end + local tMax, tMin = 0, 1000000 + for index, height in pairs(samples) do + if height > tMax then + tMax = height + end + if height < tMin then + tMin = height + end + end + return mist.utils.round(tMax - tMin, 2) + end + + function mist.groupToPoint(gpData, point, form, heading, speed, useRoads) + if type(point) == 'string' then + point = trigger.misc.getZone(point) + end + if speed then + speed = mist.utils.kmphToMps(speed) + end + + local vars = {} + vars.group = gpData + vars.form = form + vars.headingDegrees = heading + vars.speed = speed + vars.disableRoads = useRoads + vars.point = mist.utils.zoneToVec3(point) + mist.groupToRandomPoint(vars) + + return + end + + function mist.getLeadPos(group) + if type(group) == 'string' then -- group name + group = Group.getByName(group) + end + + local units = group:getUnits() + + local leader = units[1] + if not Unit.isExist(leader) then -- SHOULD be good, but if there is a bug, this code future-proofs it then. + local lowestInd = math.huge + for ind, unit in pairs(units) do + if Unit.isExist(unit) and ind < lowestInd then + lowestInd = ind + return unit:getPosition().p + end + end + end + if leader and Unit.isExist(leader) then -- maybe a little too paranoid now... + return leader:getPosition().p + end + end + +end + +--- Database tables. +-- @section mist.DBs + +--- Mission data +-- @table mist.DBs.missionData +-- @field startTime mission start time +-- @field theatre mission theatre/map e.g. Caucasus +-- @field version mission version +-- @field files mission resources + +--- Tables used as parameters. +-- @section varTables + +--- mist.flagFunc.units_in_polygon parameter table. +-- @table unitsInPolygonVars +-- @tfield table unit name table @{UnitNameTable}. +-- @tfield table zone table defining a polygon. +-- @tfield number|string flag flag to set to true. +-- @tfield[opt] number|string stopflag if set to true the function +-- will stop evaluating. +-- @tfield[opt] number maxalt maximum altitude (MSL) for the +-- polygon. +-- @tfield[opt] number req_num minimum number of units that have +-- to be in the polygon. +-- @tfield[opt] number interval sets the interval for +-- checking if units are inside of the polygon in seconds. Default: 1. +-- @tfield[opt] boolean toggle switch the flag to false if required +-- conditions are not met. Default: false. +-- @tfield[opt] table unitTableDef + +--- Logger class. +-- @type mist.Logger +do -- mist.Logger scope + mist.Logger = {} + + --- parses text and substitutes keywords with values from given array. + -- @param text string containing keywords to substitute with values + -- or a variable. + -- @param ... variables to use for substitution in string. + -- @treturn string new string with keywords substituted or + -- value of variable as string. + local function formatText(text, ...) + if type(text) ~= 'string' then + if type(text) == 'table' then + text = mist.utils.oneLineSerialize(text) + else + text = tostring(text) + end + else + for index,value in ipairs(arg) do + -- TODO: check for getmetatabel(value).__tostring + if type(value) == 'table' then + value = mist.utils.oneLineSerialize(value) + else + value = tostring(value) + end + text = text:gsub('$' .. index, value) + end + end + local fName = nil + local cLine = nil + if debug then + local dInfo = debug.getinfo(3) + fName = dInfo.name + cLine = dInfo.currentline + -- local fsrc = dinfo.short_src + --local fLine = dInfo.linedefined + end + if fName and cLine then + return fName .. '|' .. cLine .. ': ' .. text + elseif cLine then + return cLine .. ': ' .. text + else + return ' ' .. text + end + end + + local function splitText(text) + local tbl = {} + while text:len() > 4000 do + local sub = text:sub(1, 4000) + text = text:sub(4001) + table.insert(tbl, sub) + end + table.insert(tbl, text) + return tbl + end + + --- Creates a new logger. + -- Each logger has it's own tag and log level. + -- @tparam string tag tag which appears at the start of + -- every log line produced by this logger. + -- @tparam[opt] number|string level the log level defines which messages + -- will be logged and which will be omitted. Log level 3 beeing the most verbose + -- and 0 disabling all output. This can also be a string. Allowed strings are: + -- "none" (0), "error" (1), "warning" (2) and "info" (3). + -- @usage myLogger = mist.Logger:new("MyScript") + -- @usage myLogger = mist.Logger:new("MyScript", 2) + -- @usage myLogger = mist.Logger:new("MyScript", "info") + -- @treturn mist.Logger + function mist.Logger:new(tag, level) + local l = {} + l.tag = tag + setmetatable(l, self) + self.__index = self + self:setLevel(level) + return l + end + + --- Sets the level of verbosity for this logger. + -- @tparam[opt] number|string level the log level defines which messages + -- will be logged and which will be omitted. Log level 3 beeing the most verbose + -- and 0 disabling all output. This can also be a string. Allowed strings are: + -- "none" (0), "error" (1), "warning" (2) and "info" (3). + -- @usage myLogger:setLevel("info") + -- @usage -- log everything + --myLogger:setLevel(3) + function mist.Logger:setLevel(level) + if not level then + self.level = 2 + else + if type(level) == 'string' then + if level == 'none' or level == 'off' then + self.level = 0 + elseif level == 'error' then + self.level = 1 + elseif level == 'warning' or level == 'warn' then + self.level = 2 + elseif level == 'info' then + self.level = 3 + end + elseif type(level) == 'number' then + self.level = level + else + self.level = 2 + end + end + end + + --- Logs error and shows alert window. + -- This logs an error to the dcs.log and shows a popup window, + -- pausing the simulation. This works always even if logging is + -- disabled by setting a log level of "none" or 0. + -- @tparam string text the text with keywords to substitute. + -- @param ... variables to be used for substitution. + -- @usage myLogger:alert("Shit just hit the fan! WEEEE!!!11") + function mist.Logger:alert(text, ...) + text = formatText(text, unpack(arg)) + if text:len() > 4000 then + local texts = splitText(text) + for i = 1, #texts do + if i == 1 then + env.error(self.tag .. '|' .. texts[i], true) + else + env.error(texts[i]) + end + end + else + env.error(self.tag .. '|' .. text, true) + end + end + + --- Logs a message, disregarding the log level. + -- @tparam string text the text with keywords to substitute. + -- @param ... variables to be used for substitution. + -- @usage myLogger:msg("Always logged!") + function mist.Logger:msg(text, ...) + text = formatText(text, unpack(arg)) + if text:len() > 4000 then + local texts = splitText(text) + for i = 1, #texts do + if i == 1 then + env.info(self.tag .. '|' .. texts[i]) + else + env.info(texts[i]) + end + end + else + env.info(self.tag .. '|' .. text) + end + end + + --- Logs an error. + -- logs a message prefixed with this loggers tag to dcs.log as + -- long as at least the "error" log level (1) is set. + -- @tparam string text the text with keywords to substitute. + -- @param ... variables to be used for substitution. + -- @usage myLogger:error("Just an error!") + -- @usage myLogger:error("Foo is $1 instead of $2", foo, "bar") + function mist.Logger:error(text, ...) + if self.level >= 1 then + text = formatText(text, unpack(arg)) + if text:len() > 4000 then + local texts = splitText(text) + for i = 1, #texts do + if i == 1 then + env.error(self.tag .. '|' .. texts[i]) + else + env.error(texts[i]) + end + end + else + env.error(self.tag .. '|' .. text) + end + end + end + + --- Logs a warning. + -- logs a message prefixed with this loggers tag to dcs.log as + -- long as at least the "warning" log level (2) is set. + -- @tparam string text the text with keywords to substitute. + -- @param ... variables to be used for substitution. + -- @usage myLogger:warn("Mother warned you! Those $1 from the interwebs are $2", {"geeks", 1337}) + function mist.Logger:warn(text, ...) + if self.level >= 2 then + text = formatText(text, unpack(arg)) + if text:len() > 4000 then + local texts = splitText(text) + for i = 1, #texts do + if i == 1 then + env.warning(self.tag .. '|' .. texts[i]) + else + env.warning(texts[i]) + end + end + else + env.warning(self.tag .. '|' .. text) + end + end + end + + --- Logs a info. + -- logs a message prefixed with this loggers tag to dcs.log as + -- long as the highest log level (3) "info" is set. + -- @tparam string text the text with keywords to substitute. + -- @param ... variables to be used for substitution. + -- @see warn + function mist.Logger:info(text, ...) + if self.level >= 3 then + text = formatText(text, unpack(arg)) + if text:len() > 4000 then + local texts = splitText(text) + for i = 1, #texts do + if i == 1 then + env.info(self.tag .. '|' .. texts[i]) + else + env.info(texts[i]) + end + end + else + env.info(self.tag .. '|' .. text) + end + end + end + +end + +-- initialize mist +mist.init() +env.info(('Mist version ' .. mist.majorVersion .. '.' .. mist.minorVersion .. '.' .. mist.build .. ' loaded.')) + +-- vim: noet:ts=2:sw=2 diff --git a/resources/plugins/jtacautolase/plugin.json b/resources/plugins/jtacautolase/plugin.json new file mode 100644 index 00000000..837311f7 --- /dev/null +++ b/resources/plugins/jtacautolase/plugin.json @@ -0,0 +1,28 @@ +{ + "mnemonic": "jtacautolase", + "nameInUI": "JTAC Autolase", + "defaultValue": true, + "specificOptions": [ + { + "nameInUI": "Use smoke", + "mnemonic": "smoke", + "defaultValue": true + } + ], + "scriptsWorkOrders": [ + { + "file": "mist_4_3_74.lua", + "mnemonic": "mist" + }, + { + "file": "JTACAutoLase.lua", + "mnemonic": "jtacautolase-script" + } + ], + "configurationWorkOrders": [ + { + "file": "configuration.lua", + "mnemonic": "jtacautolase-config" + } + ] +} \ No newline at end of file diff --git a/resources/plugins/plugins.json b/resources/plugins/plugins.json new file mode 100644 index 00000000..21b44606 --- /dev/null +++ b/resources/plugins/plugins.json @@ -0,0 +1,5 @@ +[ + "veaf", + "jtacautolase", + "base" +] diff --git a/resources/plugins/veaf b/resources/plugins/veaf deleted file mode 160000 index 219cdffe..00000000 --- a/resources/plugins/veaf +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 219cdffef087660fe448a41e1f187c4856e9d80f From 01f83e845160f992c4ad414f4e773e640690eebc Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Mon, 19 Oct 2020 00:07:58 -0700 Subject: [PATCH 29/43] Place CAP racetracks more defensively. Ensure that we're never putting a CAP race track within 20 nmi of an enemy airfield (aside from the target airfield, if the target is hostile). --- gen/flights/flightplan.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/gen/flights/flightplan.py b/gen/flights/flightplan.py index 9bc06473..ed2b7bb0 100644 --- a/gen/flights/flightplan.py +++ b/gen/flights/flightplan.py @@ -187,6 +187,10 @@ class FlightPlanBuilder: closest_cache = ObjectiveDistanceCache.get_closest_airfields(location) for airfield in closest_cache.closest_airfields: + # If the mission is a BARCAP of an enemy airfield, find the *next* + # closest enemy airfield. + if airfield == self.package.target: + continue if airfield.captured != self.is_player: closest_airfield = airfield break @@ -198,10 +202,19 @@ class FlightPlanBuilder: closest_airfield.position ) + min_distance_from_enemy = nm_to_meter(20) + distance_to_airfield = int(closest_airfield.position.distance_to_point( + self.package.target.position + )) + distance_to_no_fly = distance_to_airfield - min_distance_from_enemy + min_cap_distance = min(self.doctrine.cap_min_distance_from_cp, + distance_to_no_fly) + max_cap_distance = min(self.doctrine.cap_max_distance_from_cp, + distance_to_no_fly) + end = location.position.point_from_heading( heading, - random.randint(self.doctrine.cap_min_distance_from_cp, - self.doctrine.cap_max_distance_from_cp) + random.randint(min_cap_distance, max_cap_distance) ) diameter = random.randint( self.doctrine.cap_min_track_length, From aa96ce713499312712c69e94a2530ec951849e82 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Mon, 19 Oct 2020 01:21:49 -0700 Subject: [PATCH 30/43] Fix cancel of new package. --- qt_ui/windows/mission/QPackageDialog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qt_ui/windows/mission/QPackageDialog.py b/qt_ui/windows/mission/QPackageDialog.py index 2ab035c3..e9837316 100644 --- a/qt_ui/windows/mission/QPackageDialog.py +++ b/qt_ui/windows/mission/QPackageDialog.py @@ -205,7 +205,7 @@ class QNewPackageDialog(QPackageDialog): def on_cancel(self) -> None: super().on_cancel() for flight in self.package_model.package.flights: - self.game_model.game.aircraft_inventory.return_from_flight(flight) + self.game.aircraft_inventory.return_from_flight(flight) class QEditPackageDialog(QPackageDialog): From c2d615315ea695b42e4467abec41c5ca1809e792 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Mon, 19 Oct 2020 20:29:55 -0700 Subject: [PATCH 31/43] Add client slot selection to new flight window. --- qt_ui/windows/mission/flight/QFlightCreator.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/qt_ui/windows/mission/flight/QFlightCreator.py b/qt_ui/windows/mission/flight/QFlightCreator.py index e514b9d7..07082848 100644 --- a/qt_ui/windows/mission/flight/QFlightCreator.py +++ b/qt_ui/windows/mission/flight/QFlightCreator.py @@ -56,7 +56,18 @@ class QFlightCreator(QDialog): layout.addLayout(QLabeledWidget("Airfield:", self.airfield_selector)) self.flight_size_spinner = QFlightSizeSpinner() - layout.addLayout(QLabeledWidget("Count:", self.flight_size_spinner)) + layout.addLayout(QLabeledWidget("Size:", self.flight_size_spinner)) + + self.client_slots_spinner = QFlightSizeSpinner( + min_size=0, + max_size=self.flight_size_spinner.value(), + default_size=0 + ) + self.flight_size_spinner.valueChanged.connect( + lambda v: self.client_slots_spinner.setMaximum(v) + ) + layout.addLayout( + QLabeledWidget("Client Slots:", self.client_slots_spinner)) layout.addStretch() @@ -96,6 +107,7 @@ class QFlightCreator(QDialog): start_type = "Warm" flight = Flight(aircraft, size, origin, task, start_type) flight.scheduled_in = self.package.delay + flight.client_count = self.client_slots_spinner.value() # noinspection PyUnresolvedReferences self.created.emit(flight) From 916d1eec9644adf9a5b326c8de88d7bb94e58ea8 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Mon, 19 Oct 2020 20:46:18 -0700 Subject: [PATCH 32/43] Limit flight size to available aircraft. --- qt_ui/widgets/combos/QOriginAirfieldSelector.py | 6 ++++++ qt_ui/windows/mission/flight/QFlightCreator.py | 7 +++++++ 2 files changed, 13 insertions(+) diff --git a/qt_ui/widgets/combos/QOriginAirfieldSelector.py b/qt_ui/widgets/combos/QOriginAirfieldSelector.py index b0995a6b..904ec114 100644 --- a/qt_ui/widgets/combos/QOriginAirfieldSelector.py +++ b/qt_ui/widgets/combos/QOriginAirfieldSelector.py @@ -39,3 +39,9 @@ class QOriginAirfieldSelector(QComboBox): self.addItem(f"{origin.name} ({available} available)", origin) self.model().sort(0) self.update() + + @property + def available(self) -> int: + origin = self.currentData() + inventory = self.global_inventory.for_control_point(origin) + return inventory.available(self.aircraft) diff --git a/qt_ui/windows/mission/flight/QFlightCreator.py b/qt_ui/windows/mission/flight/QFlightCreator.py index 07082848..a2ca14ee 100644 --- a/qt_ui/windows/mission/flight/QFlightCreator.py +++ b/qt_ui/windows/mission/flight/QFlightCreator.py @@ -53,9 +53,11 @@ class QFlightCreator(QDialog): [cp for cp in game.theater.controlpoints if cp.captured], self.aircraft_selector.currentData() ) + self.aircraft_selector.currentIndexChanged.connect(self.update_max_size) layout.addLayout(QLabeledWidget("Airfield:", self.airfield_selector)) self.flight_size_spinner = QFlightSizeSpinner() + self.update_max_size() layout.addLayout(QLabeledWidget("Size:", self.flight_size_spinner)) self.client_slots_spinner = QFlightSizeSpinner( @@ -116,3 +118,8 @@ class QFlightCreator(QDialog): def on_aircraft_changed(self, index: int) -> None: new_aircraft = self.aircraft_selector.itemData(index) self.airfield_selector.change_aircraft(new_aircraft) + + def update_max_size(self) -> None: + self.flight_size_spinner.setMaximum( + min(self.airfield_selector.available, 4) + ) From f65595c626aef7da79b3bd98a289ff686c0bce0b Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Mon, 19 Oct 2020 21:17:24 -0700 Subject: [PATCH 33/43] Automatically select newly created packages. --- qt_ui/widgets/ato.py | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/qt_ui/widgets/ato.py b/qt_ui/widgets/ato.py index 32178381..bc45fac9 100644 --- a/qt_ui/widgets/ato.py +++ b/qt_ui/widgets/ato.py @@ -337,6 +337,7 @@ class QPackageList(QListView): self.setItemDelegate(PackageDelegate()) self.setIconSize(QSize(91, 24)) self.setSelectionBehavior(QAbstractItemView.SelectItems) + self.model().rowsInserted.connect(self.on_new_packages) @property def selected_item(self) -> Optional[Package]: @@ -346,6 +347,14 @@ class QPackageList(QListView): return None return self.ato_model.package_at_index(index) + def on_new_packages(self, _parent: QModelIndex, first: int, + _last: int) -> None: + # Select the newly created pacakges. This should only ever happen due to + # the player saving a new package, so selecting it helps them view/edit + # it faster. + self.selectionModel().setCurrentIndex(self.model().index(first, 0), + QItemSelectionModel.Select) + class QPackagePanel(QGroupBox): """The package display portion of the ATO panel. @@ -357,7 +366,7 @@ class QPackagePanel(QGroupBox): def __init__(self, model: AtoModel) -> None: super().__init__("Packages") self.ato_model = model - self.ato_model.layoutChanged.connect(self.on_selection_changed) + self.ato_model.layoutChanged.connect(self.on_current_changed) self.vbox = QVBoxLayout() self.setLayout(self.vbox) @@ -378,15 +387,15 @@ class QPackagePanel(QGroupBox): self.delete_button.clicked.connect(self.on_delete) self.button_row.addWidget(self.delete_button) - self.selection_changed.connect(self.on_selection_changed) - self.on_selection_changed() + self.current_changed.connect(self.on_current_changed) + self.on_current_changed() @property - def selection_changed(self): + def current_changed(self): """Returns the signal emitted when the flight selection changes.""" - return self.package_list.selectionModel().selectionChanged + return self.package_list.selectionModel().currentChanged - def on_selection_changed(self) -> None: + def on_current_changed(self) -> None: """Updates the status of the edit and delete buttons.""" index = self.package_list.currentIndex() enabled = index.isValid() @@ -436,8 +445,7 @@ class QAirTaskingOrderPanel(QSplitter): self.ato_model = game_model.ato_model self.package_panel = QPackagePanel(self.ato_model) - self.package_panel.selection_changed.connect(self.on_package_change) - self.ato_model.rowsInserted.connect(self.on_package_change) + self.package_panel.current_changed.connect(self.on_package_change) self.addWidget(self.package_panel) self.flight_panel = QFlightPanel(game_model) From 5023e0d30f031698610ca34a8f232e3f1152abec Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Mon, 19 Oct 2020 21:34:17 -0700 Subject: [PATCH 34/43] Fix disabled delete button in package UI. The selection changed handler isn't called for the initial selection. --- qt_ui/windows/mission/QPackageDialog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qt_ui/windows/mission/QPackageDialog.py b/qt_ui/windows/mission/QPackageDialog.py index e9837316..4ee1a0a1 100644 --- a/qt_ui/windows/mission/QPackageDialog.py +++ b/qt_ui/windows/mission/QPackageDialog.py @@ -102,7 +102,7 @@ class QPackageDialog(QDialog): self.delete_flight_button = QPushButton("Delete Selected") self.delete_flight_button.setProperty("style", "btn-danger") self.delete_flight_button.clicked.connect(self.on_delete_flight) - self.delete_flight_button.setEnabled(False) + self.delete_flight_button.setEnabled(model.rowCount() > 0) self.button_layout.addWidget(self.delete_flight_button) self.button_layout.addStretch() From 4125f6ec06479226ee3e1f7f0cc33b80b6bc2639 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Mon, 19 Oct 2020 22:26:31 -0700 Subject: [PATCH 35/43] Don't scale waypoint info text size when zooming. --- qt_ui/widgets/map/QLiberationMap.py | 39 ++++++++++++++++++----------- 1 file changed, 25 insertions(+), 14 deletions(-) diff --git a/qt_ui/widgets/map/QLiberationMap.py b/qt_ui/widgets/map/QLiberationMap.py index 6ccde6da..567bce80 100644 --- a/qt_ui/widgets/map/QLiberationMap.py +++ b/qt_ui/widgets/map/QLiberationMap.py @@ -4,8 +4,16 @@ import datetime import logging from typing import List, Optional, Tuple -from PySide2.QtCore import Qt, QPointF -from PySide2.QtGui import QBrush, QColor, QPen, QPixmap, QWheelEvent, QPolygonF +from PySide2.QtCore import QPointF, Qt +from PySide2.QtGui import ( + QBrush, + QColor, + QFont, + QPen, + QPixmap, + QPolygonF, + QWheelEvent, +) from PySide2.QtWidgets import ( QFrame, QGraphicsItem, @@ -46,6 +54,9 @@ class QLiberationMap(QGraphicsView): self.game_model = game_model self.game: Optional[Game] = game_model.game + self.waypoint_info_font = QFont() + self.waypoint_info_font.setPointSize(12) + self.flight_path_items: List[QGraphicsItem] = [] # A tuple of (package index, flight index), or none. self.selected_flight: Optional[Tuple[int, int]] = None @@ -345,19 +356,19 @@ class QLiberationMap(QGraphicsView): pen = QPen(QColor("black"), 0.3) brush = QColor("white") - def draw_text(text: str, x: int, y: int) -> None: - item = scene.addSimpleText(text) - item.setBrush(brush) - item.setPen(pen) - item.moveBy(x, y) - item.setZValue(2) - self.flight_path_items.append(item) + text = "\n".join([ + f"{number} {waypoint.name}", + f"{altitude} ft {altitude_type}", + tot, + ]) - draw_text(f"{number} {waypoint.name}", position[0] + 8, - position[1] - 15) - draw_text(f"{altitude} ft {altitude_type}", position[0] + 8, - position[1] - 5) - draw_text(tot, position[0] + 8, position[1] + 5) + item = scene.addSimpleText(text, self.waypoint_info_font) + item.setFlag(QGraphicsItem.ItemIgnoresTransformations) + item.setBrush(brush) + item.setPen(pen) + item.moveBy(position[0] + 8, position[1]) + item.setZValue(2) + self.flight_path_items.append(item) def draw_flight_path(self, scene: QGraphicsScene, pos0: Tuple[int, int], pos1: Tuple[int, int], player: bool, From 1c4f255c7f470ce6be299a50ed83e37b52a9d37e Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Mon, 19 Oct 2020 22:44:54 -0700 Subject: [PATCH 36/43] Add waypoint departure time to the kneeboard. --- gen/aircraft.py | 5 +++-- gen/flights/flight.py | 3 ++- gen/kneeboard.py | 3 ++- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/gen/aircraft.py b/gen/aircraft.py index 88824e36..f700da0f 100644 --- a/gen/aircraft.py +++ b/gen/aircraft.py @@ -1135,8 +1135,9 @@ class HoldPointBuilder(PydcsWaypointBuilder): altitude=waypoint.alt, pattern=OrbitAction.OrbitPattern.Circle )) - loiter.stop_after_time( - self.timing.push_time(self.flight, self.waypoint)) + push_time = self.timing.push_time(self.flight, self.waypoint) + self.waypoint.departure_time = push_time + loiter.stop_after_time(push_time) waypoint.add_task(loiter) return waypoint diff --git a/gen/flights/flight.py b/gen/flights/flight.py index bd879daa..5aba1d83 100644 --- a/gen/flights/flight.py +++ b/gen/flights/flight.py @@ -91,11 +91,12 @@ class FlightWaypoint: self.only_for_player = False self.data = None - # This is set very late by the air conflict generator (part of mission + # These are set very late by the air conflict generator (part of mission # generation). We do it late so that we don't need to propagate changes # to waypoint times whenever the player alters the package TOT or the # flight's offset in the UI. self.tot: Optional[int] = None + self.departure_time: Optional[int] = None @classmethod def from_pydcs(cls, point: MovingPoint, diff --git a/gen/kneeboard.py b/gen/kneeboard.py index cea2e591..3ef775ef 100644 --- a/gen/kneeboard.py +++ b/gen/kneeboard.py @@ -145,6 +145,7 @@ class FlightPlanBuilder: waypoint.waypoint.pretty_name, str(int(units.meters_to_feet(waypoint.waypoint.alt))), self._format_time(waypoint.waypoint.tot), + self._format_time(waypoint.waypoint.departure_time), ]) def _format_time(self, time: Optional[int]) -> str: @@ -187,7 +188,7 @@ class BriefingPage(KneeboardPage): for num, waypoint in enumerate(self.flight.waypoints): flight_plan_builder.add_waypoint(num, waypoint) writer.table(flight_plan_builder.build(), - headers=["STPT", "Action", "Alt", "TOT"]) + headers=["#", "Action", "Alt", "Time", "Departure"]) writer.heading("Comm Ladder") comms = [] From eb69d010676684bd730b0027f592120412d5864b Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Mon, 19 Oct 2020 23:25:46 -0700 Subject: [PATCH 37/43] Add distance and ground speed to the kneeboard. --- gen/flights/flight.py | 27 ++++++++++--------------- gen/kneeboard.py | 47 ++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 55 insertions(+), 19 deletions(-) diff --git a/gen/flights/flight.py b/gen/flights/flight.py index 5aba1d83..378f50b8 100644 --- a/gen/flights/flight.py +++ b/gen/flights/flight.py @@ -1,9 +1,11 @@ from enum import Enum -from typing import Dict, Iterable, Optional +from typing import Dict, Iterable, List, Optional + +from dcs.mapping import Point +from dcs.point import MovingPoint, PointAction +from dcs.unittype import UnitType from game import db -from dcs.unittype import UnitType -from dcs.point import MovingPoint, PointAction from theater.controlpoint import ControlPoint, MissionTarget @@ -98,11 +100,15 @@ class FlightWaypoint: self.tot: Optional[int] = None self.departure_time: Optional[int] = None + @property + def position(self) -> Point: + return Point(self.x, self.y) + @classmethod def from_pydcs(cls, point: MovingPoint, from_cp: ControlPoint) -> "FlightWaypoint": - waypoint = FlightWaypoint(point.position.x, point.position.y, - point.alt) + waypoint = FlightWaypoint(FlightWaypointType.NAV, point.position.x, + point.position.y, point.alt) waypoint.alt_type = point.alt_type # Other actions exist... but none of them *should* be the first # waypoint for a flight. @@ -159,14 +165,3 @@ class Flight: if waypoint.waypoint_type in types: return waypoint return None - - -# Test -if __name__ == '__main__': - from dcs.planes import A_10C - from theater import ControlPoint, Point, List - - from_cp = ControlPoint(0, "AA", Point(0, 0), Point(0, 0), [], 0, 0) - f = Flight(A_10C(), 4, from_cp, FlightType.CAS, "Cold") - f.scheduled_in = 50 - print(f) diff --git a/gen/kneeboard.py b/gen/kneeboard.py index 3ef775ef..a0c4c7a5 100644 --- a/gen/kneeboard.py +++ b/gen/kneeboard.py @@ -29,16 +29,19 @@ from pathlib import Path from typing import Dict, List, Optional, Tuple from PIL import Image, ImageDraw, ImageFont +from dcs.mapping import Point from dcs.mission import Mission from dcs.unittype import FlyingType from tabulate import tabulate +from game.utils import meter_to_nm from . import units from .aircraft import AIRCRAFT_DATA, FlightData from .airfields import RunwayData from .airsupportgen import AwacsInfo, TankerInfo from .briefinggen import CommInfo, JtacInfo, MissionInfoGenerator from .flights.flight import FlightWaypoint, FlightWaypointType +from .flights.traveltime import TravelTime from .radios import RadioFrequency @@ -111,6 +114,7 @@ class FlightPlanBuilder: self.start_time = start_time self.rows: List[List[str]] = [] self.target_points: List[NumberedWaypoint] = [] + self.last_waypoint: Optional[FlightWaypoint] = None def add_waypoint(self, waypoint_num: int, waypoint: FlightWaypoint) -> None: if waypoint.waypoint_type == FlightWaypointType.TARGET_POINT: @@ -136,23 +140,59 @@ class FlightPlanBuilder: f"{first_waypoint_num}-{last_waypoint_num}", "Target points", "0", + self._waypoint_distance(self.target_points[0].waypoint), + self._ground_speed(self.target_points[0].waypoint), self._format_time(self.target_points[0].waypoint.tot), + self._format_time(self.target_points[0].waypoint.departure_time), ]) + self.last_waypoint = self.target_points[-1].waypoint def add_waypoint_row(self, waypoint: NumberedWaypoint) -> None: self.rows.append([ str(waypoint.number), waypoint.waypoint.pretty_name, str(int(units.meters_to_feet(waypoint.waypoint.alt))), + self._waypoint_distance(waypoint.waypoint), + self._ground_speed(waypoint.waypoint), self._format_time(waypoint.waypoint.tot), self._format_time(waypoint.waypoint.departure_time), ]) + self.last_waypoint = waypoint.waypoint def _format_time(self, time: Optional[int]) -> str: if time is None: return "" local_time = self.start_time + datetime.timedelta(seconds=time) - return local_time.strftime(f"%H:%M:%S LOCAL") + return local_time.strftime(f"%H:%M:%S") + + def _waypoint_distance(self, waypoint: FlightWaypoint) -> str: + if self.last_waypoint is None: + return "-" + + distance = meter_to_nm(self.last_waypoint.position.distance_to_point( + waypoint.position + )) + return f"{distance} NM" + + def _ground_speed(self, waypoint: FlightWaypoint) -> str: + if self.last_waypoint is None: + return "-" + + if waypoint.tot is None: + return "-" + + if self.last_waypoint.departure_time is not None: + last_time = self.last_waypoint.departure_time + elif self.last_waypoint.tot is not None: + last_time = self.last_waypoint.tot + else: + return "-" + + distance = meter_to_nm(self.last_waypoint.position.distance_to_point( + waypoint.position + )) + duration = (waypoint.tot - last_time) / 3600 + return f"{int(distance / duration)} kt" def build(self) -> List[List[str]]: return self.rows @@ -187,8 +227,9 @@ class BriefingPage(KneeboardPage): flight_plan_builder = FlightPlanBuilder(self.start_time) for num, waypoint in enumerate(self.flight.waypoints): flight_plan_builder.add_waypoint(num, waypoint) - writer.table(flight_plan_builder.build(), - headers=["#", "Action", "Alt", "Time", "Departure"]) + writer.table(flight_plan_builder.build(), headers=[ + "#", "Action", "Alt", "Dist", "GSPD", "Time", "Departure" + ]) writer.heading("Comm Ladder") comms = [] From f8ac39fb82e0ec5c1fa87eca21b1324933d75f5f Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Tue, 20 Oct 2020 00:10:37 -0700 Subject: [PATCH 38/43] Fix min/max inversion in wind setting. --- game/weather.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/game/weather.py b/game/weather.py index a9ac5141..d6775614 100644 --- a/game/weather.py +++ b/game/weather.py @@ -71,7 +71,7 @@ class Weather: return WindConditions( # Always some wind to make the smoke move a bit. - at_0m=Wind(wind_direction, min(1, base_wind * at_0m_factor)), + at_0m=Wind(wind_direction, max(1, base_wind * at_0m_factor)), at_2000m=Wind(wind_direction, base_wind * at_2000m_factor), at_8000m=Wind(wind_direction, base_wind * at_8000m_factor) ) From 84beb2dfe52cde87c800cd34cbf30b531261400f Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Tue, 20 Oct 2020 00:15:22 -0700 Subject: [PATCH 39/43] Remove dead code. --- gen/flights/ai_flight_planner.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/gen/flights/ai_flight_planner.py b/gen/flights/ai_flight_planner.py index a1473433..a88534b3 100644 --- a/gen/flights/ai_flight_planner.py +++ b/gen/flights/ai_flight_planner.py @@ -389,9 +389,6 @@ class CoalitionMissionPlanner: MAX_SEAD_RANGE = nm_to_meter(150) MAX_STRIKE_RANGE = nm_to_meter(150) - NON_CAP_MIN_DELAY = 1 - NON_CAP_MAX_DELAY = 5 - def __init__(self, game: Game, is_player: bool) -> None: self.game = game self.is_player = is_player From cab5825b723a61a2919d232297e5d1fc06c6c9fd Mon Sep 17 00:00:00 2001 From: Justin Lovell Date: Tue, 20 Oct 2020 21:49:32 +1100 Subject: [PATCH 40/43] Flexible Dedicated Hosting Options * Fixed minor errors on the original LUA scripting * Refactored code to be self-contained to a function * Changed the search logic to use an environment variable first, then fallback into other search options --- resources/scripts/dcs_liberation.lua | 76 +++++++++++++++------------- 1 file changed, 42 insertions(+), 34 deletions(-) diff --git a/resources/scripts/dcs_liberation.lua b/resources/scripts/dcs_liberation.lua index 70a5c239..8017b1f5 100644 --- a/resources/scripts/dcs_liberation.lua +++ b/resources/scripts/dcs_liberation.lua @@ -39,46 +39,54 @@ write_state = function() -- messageAll("Done writing DCS Liberation state.") end -debriefing_file_location = nil -if dcsLiberation then - debriefing_file_location = dcsLiberation.installPath -end -if debriefing_file_location then - logger:info("Using DCS Liberation install folder for state.json") -else + +local function discoverDebriefingFilePath() + local function insertFileName(directoryOrFilePath, overrideFileName) + if overrideFileName then + logger:info("Using LIBERATION_EXPORT_STAMPED_STATE to locate the state.json") + return directoryOrFilePath .. os.time() .. "-state.json" + end + + local filename = "state.json" + if not (directoryOrFilePath:sub(-#filename) == filename) then + return directoryOrFilePath .. filename + end + + return directoryOrFilePath + end + + -- establish a search pattern into the following modes + -- 1. Environment variable mode, to support dedicated server hosting + -- 2. Embedded DCS Liberation Generation, to support locally hosted single player + -- 3. Retain the classic TEMP directory logic + if os then - debriefing_file_location = os.getenv("LIBERATION_EXPORT_DIR") - if debriefing_file_location then debriefing_file_location = debriefing_file_location .. "\\" end - end - if debriefing_file_location then - logger:info("Using LIBERATION_EXPORT_DIR environment variable for state.json") - else - if os then - debriefing_file_location = os.getenv("TEMP") - if debriefing_file_location then debriefing_file_location = debriefing_file_location .. "\\" end - end - if debriefing_file_location then - logger:info("Using TEMP environment variable for state.json") - else - if lfs then - debriefing_file_location = lfs.writedir() - end - if debriefing_file_location then - logger:info("Using DCS working directory for state.json") - end + local exportDirectory = os.getenv("LIBERATION_EXPORT_DIR") + + if exportDirectory then + logger:info("Using LIBERATION_EXPORT_DIR to locate the state.json") + local useCurrentStamping = os.getenv("LIBERATION_EXPORT_STAMPED_STATE") + exportDirectory = exportDirectory .. "\\" + return insertFileName(exportDirectory, useCurrentStamping) end end -end -if debriefing_file_location then - local filename = "state.json" - if not debriefing_file_location:sub(-#filename) == filename then - debriefing_file_location = debriefing_file_location .. filename + + if dcsLiberation then + logger:info("Using DCS Liberation install folder for state.json") + return insertFileName(dcsLiberation.installPath) + end + + if lfs then + logger:info("Using DCS working directory for state.json") + return insertFileName(lfs.writedir()) end - logger:info(string.format("DCS Liberation state will be written as json to [[%s]]",debriefing_file_location)) -else - logger:error("No usable storage path for state.json") end + +debriefing_file_location = discoverDebriefingFilePath() +logger:info(string.format("DCS Liberation state will be written as json to [[%s]]",debriefing_file_location)) + + write_state_error_handling = function() if pcall(write_state) then -- messageAll("Written DCS Liberation state to "..debriefing_file_location) From 41d50204670328ce22bf573f7a38b0198d5a1fc2 Mon Sep 17 00:00:00 2001 From: David Pierron Date: Tue, 20 Oct 2020 22:25:39 +0200 Subject: [PATCH 41/43] bug correction - when running a new campaing without accessing the plugin setup screen --- plugin/luaplugin.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/plugin/luaplugin.py b/plugin/luaplugin.py index 8f92ad76..7bc4f57a 100644 --- a/plugin/luaplugin.py +++ b/plugin/luaplugin.py @@ -73,7 +73,7 @@ class LuaPlugin(): def setupUI(self, settingsWindow, row:int): # set the game settings - self.settings = settingsWindow.game.settings + self.setSettings(settingsWindow.game.settings) if not self.skipUI: # create the plugin choice checkbox interface @@ -95,7 +95,7 @@ class LuaPlugin(): # browse each option in the specific options list row = 0 for specificOption in self.specificOptions: - nameInSettings = self.nameInSettings + specificOption.mnemonic + nameInSettings = self.nameInSettings + "." + specificOption.mnemonic if not nameInSettings in self.settings.plugins: self.settings.plugins[nameInSettings] = specificOption.defaultValue @@ -136,7 +136,7 @@ class LuaPlugin(): # do the same for each option in the specific options list for specificOption in self.specificOptions: - nameInSettings = self.nameInSettings + specificOption.mnemonic + nameInSettings = self.nameInSettings + "." + specificOption.mnemonic self.settings.plugins[nameInSettings] = specificOption.uiWidget.isChecked() # disable or enable the UI in the plugins special page @@ -144,7 +144,7 @@ class LuaPlugin(): def injectScripts(self, operation): # set the game settings - self.settings = operation.game.settings + self.setSettings(operation.game.settings) # execute the work order if self.scriptsWorkOrders != None: @@ -156,13 +156,13 @@ class LuaPlugin(): def injectConfiguration(self, operation): # set the game settings - self.settings = operation.game.settings + self.setSettings(operation.game.settings) # inject the plugin options if len(self.specificOptions) > 0: defineAllOptions = "" for specificOption in self.specificOptions: - nameInSettings = self.nameInSettings + specificOption.mnemonic + nameInSettings = self.nameInSettings + "." + specificOption.mnemonic value = "true" if self.settings.plugins[nameInSettings] else "false" defineAllOptions += f" dcsLiberation.plugins.{self.mnemonic}.{specificOption.mnemonic} = {value} \n" From 44c976948dc0913303a8f74b74b2c9aa18b60e22 Mon Sep 17 00:00:00 2001 From: David Pierron Date: Tue, 20 Oct 2020 23:02:46 +0200 Subject: [PATCH 42/43] bug correction in the JTACautolase LUA config --- game/operation/operation.py | 6 ------ .../jtacautolase/jtacautolase-config.lua | 17 ++++++++--------- resources/plugins/jtacautolase/plugin.json | 2 +- 3 files changed, 9 insertions(+), 16 deletions(-) diff --git a/game/operation/operation.py b/game/operation/operation.py index 3284c800..c83f903f 100644 --- a/game/operation/operation.py +++ b/game/operation/operation.py @@ -369,12 +369,6 @@ dcsLiberation = {} -- the base location for state.json; if non-existent, it'll be replaced with LIBERATION_EXPORT_DIR, TEMP, or DCS working directory dcsLiberation.installPath=""" + state_location + """ --- you can override dcsLiberation.JTACAutoLase to make it use your own function ; it will be called with these parameters : ({jtac.unit_name}, {jtac.code}, {smoke}, 'vehicle') for all JTACs -if ctld then - dcsLiberation.JTACAutoLase=ctld.JTACAutoLase -elseif JTACAutoLase then - dcsLiberation.JTACAutoLase=JTACAutoLase -end """ # Process the tankers lua += """ diff --git a/resources/plugins/jtacautolase/jtacautolase-config.lua b/resources/plugins/jtacautolase/jtacautolase-config.lua index 04d0c293..47a88edf 100644 --- a/resources/plugins/jtacautolase/jtacautolase-config.lua +++ b/resources/plugins/jtacautolase/jtacautolase-config.lua @@ -9,29 +9,28 @@ env.info("DCSLiberation|JTACAutolase plugin - configuration") if dcsLiberation then - veaf.logTrace("dcsLiberation") + env.info(string.format("DCSLiberation|JTACAutolase plugin - dcsLiberation")) -- specific options local smoke = false -- retrieve specific options values if dcsLiberation.plugins then - veaf.logTrace("dcsLiberation.plugins") + env.info(string.format("DCSLiberation|JTACAutolase plugin - dcsLiberation.plugins")) if dcsLiberation.plugins.jtacautolase then - veaf.logTrace("dcsLiberation.plugins.jtacautolase") - veaf.logTrace(string.format("dcsLiberation.plugins.jtacautolase.smoke=%s",veaf.p(dcsLiberation.plugins.jtacautolase.smoke))) - + env.info(string.format("DCSLiberation|JTACAutolase plugin - dcsLiberation.plugins.jtacautolase")) smoke = dcsLiberation.plugins.jtacautolase.smoke + env.info(string.format("DCSLiberation|JTACAutolase plugin - smoke = %s",tostring(smoke))) end end - veaf.logTrace(string.format("smoke=%s",veaf.p(smoke))) - -- actual configuration code for _, jtac in pairs(dcsLiberation.JTACs) do - if dcsLiberation.JTACAutoLase then - dcsLiberation.JTACAutoLase(jtac.dcsUnit, jtac.code, smoke, 'vehicle') + env.info(string.format("DCSLiberation|JTACAutolase plugin - setting up %s",jtac.dcsUnit)) + if JTACAutoLase then + env.info(string.format("DCSLiberation|JTACAutolase plugin - calling dcsLiberation.JTACAutoLase")) + JTACAutoLase(jtac.dcsUnit, jtac.laserCode, smoke, 'vehicle') end end diff --git a/resources/plugins/jtacautolase/plugin.json b/resources/plugins/jtacautolase/plugin.json index 837311f7..33ce09dd 100644 --- a/resources/plugins/jtacautolase/plugin.json +++ b/resources/plugins/jtacautolase/plugin.json @@ -21,7 +21,7 @@ ], "configurationWorkOrders": [ { - "file": "configuration.lua", + "file": "jtacautolase-config.lua", "mnemonic": "jtacautolase-config" } ] From f5aa34260232153e92260696d0e0289db641ff39 Mon Sep 17 00:00:00 2001 From: David Pierron Date: Wed, 21 Oct 2020 11:32:39 +0200 Subject: [PATCH 43/43] Removed VEAF submodule --- .gitmodules | 6 ------ 1 file changed, 6 deletions(-) diff --git a/.gitmodules b/.gitmodules index 4d2ad31a..d8db9cf5 100644 --- a/.gitmodules +++ b/.gitmodules @@ -2,9 +2,3 @@ path = pydcs url = https://github.com/pydcs/dcs branch = master -[submodule "plugin/veaf"] - path = plugin/veaf - url = https://github.com/VEAF/dcs-liberation-veaf-framework -[submodule "resources/plugins/veaf"] - path = resources/plugins/veaf - url = https://github.com/VEAF/dcs-liberation-veaf-framework