diff --git a/Moose Development/Moose/Modules.lua b/Moose Development/Moose/Modules.lua index 20ca5666e..e1237943e 100644 --- a/Moose Development/Moose/Modules.lua +++ b/Moose Development/Moose/Modules.lua @@ -138,4 +138,13 @@ __Moose.Include( MOOSE_DEVELOPMENT_FOLDER..'/Moose/Navigation/Beacons.lua' ) __Moose.Include( MOOSE_DEVELOPMENT_FOLDER..'/Moose/Navigation/Radios.lua' ) __Moose.Include( MOOSE_DEVELOPMENT_FOLDER..'/Moose/Navigation/Towns.lua' ) +__Moose.Include( MOOSE_DEVELOPMENT_FOLDER..'/Moose/Python/MooseBridgeJson.lua' ) +__Moose.Include( MOOSE_DEVELOPMENT_FOLDER..'/Moose/Python/MooseBridge.lua' ) +__Moose.Include( MOOSE_DEVELOPMENT_FOLDER..'/Moose/Python/MooseBridgeSocketTuningExtension.lua' ) +__Moose.Include( MOOSE_DEVELOPMENT_FOLDER..'/Moose/Python/MooseBridgeDcsEventsExtension.lua' ) +__Moose.Include( MOOSE_DEVELOPMENT_FOLDER..'/Moose/Python/MooseBridgeAuftragExecutionExtension.lua' ) +__Moose.Include( MOOSE_DEVELOPMENT_FOLDER..'/Moose/Python/MooseBridgeAuftragTraceExtension.lua' ) +__Moose.Include( MOOSE_DEVELOPMENT_FOLDER..'/Moose/Python/MooseBridgeIntelExtension.lua' ) +__Moose.Include( MOOSE_DEVELOPMENT_FOLDER..'/Moose/Python/MooseBridgePayloadExtension.lua' ) + __Moose.Include( MOOSE_DEVELOPMENT_FOLDER..'/Moose/Globals.lua' ) diff --git a/Moose Development/Moose/Python/MooseBridge.lua b/Moose Development/Moose/Python/MooseBridge.lua new file mode 100644 index 000000000..1599286c2 --- /dev/null +++ b/Moose Development/Moose/Python/MooseBridge.lua @@ -0,0 +1,2282 @@ +MOOSE_BRIDGE = MOOSE_BRIDGE or {} +MOOSE_BRIDGE.ClassName = "MOOSE_BRIDGE" + +local json = MOOSE_BRIDGE_JSON +if not json then error("Load MooseBridgeJson.lua before MooseBridge.lua") end + +local function mission_time() + if timer and timer.getTime then return timer.getTime() end + return nil +end + +local function dcs_time() + if timer and timer.getAbsTime then return timer.getAbsTime() end + return nil +end + +local function mission_date() + if not UTILS or not UTILS.GetDCSMissionDate then return nil end + local ok, value = pcall(function() return UTILS.GetDCSMissionDate() end) + if ok then return value end + return nil +end + +local function wall_time() + if os and os.date then return os.date("!%Y-%m-%dT%H:%M:%SZ") end + return nil +end + +local function coalition_from_name(name) + if name == "blue" then return coalition.side.BLUE end + if name == "red" then return coalition.side.RED end + if name == "neutral" then return coalition.side.NEUTRAL end + return nil +end + +local function safe_tostring(value) + if value == nil then return "nil" end + return tostring(value) +end + +local function string_or_nil(value) + if value == nil then return nil end + return tostring(value) +end + +local function append_unique(list, seen, value) + if value == nil then return end + local key = tostring(value) + if seen[key] then return end + list[#list + 1] = key + seen[key] = true +end + +function MOOSE_BRIDGE:New(host, port) + local self = BASE and BASE:Inherit(self, BASE:New()) or {} + if not BASE then setmetatable(self, { __index = MOOSE_BRIDGE }) end + self.Host = host or "127.0.0.1" + self.Port = port or 42000 + self.Socket = nil + self.Scheduler = nil + self.Connected = false + self.Sequence = 0 + self.DebugOverlays = {} + self.OutQueue = {} + self.CommandHandlers = {} + self.RegisteredZones = {} + self.RegisteredOpsZones = {} + self.RegisteredOpsGroups = {} + self.RegisteredCommanders = {} + self.ConnectRetryDelay = 5 + self.TickInterval = 0.2 + self.HeartbeatInterval = 5 + self.LastHeartbeat = 0 + self.LastConnectAttempt = -9999 + self.MissionDate = mission_date() + self:RegisterDefaultCommands() + return self +end + +function MOOSE_BRIDGE:_Log(message) + local line = "[MOOSE_BRIDGE] " .. safe_tostring(message) + if env and env.info then env.info(line) else print(line) end +end + +function MOOSE_BRIDGE:Start() + self:_Log("Starting bridge to " .. self.Host .. ":" .. tostring(self.Port)) + if not SCHEDULER then error("MOOSE_BRIDGE requires MOOSE SCHEDULER") end + self.Scheduler = SCHEDULER:New(self, self._Tick, {}, 0, self.TickInterval) + if self._StartDcsEventForwarding then self:_StartDcsEventForwarding() end + return self +end + +function MOOSE_BRIDGE:Stop() + if self._StopDcsEventForwarding then self:_StopDcsEventForwarding() end + if self._ClearDebugOverlays then self:_ClearDebugOverlays() end + if self.Scheduler then self.Scheduler:Stop(); self.Scheduler = nil end + if self.Socket then self.Socket:close(); self.Socket = nil end + self.Connected = false + return self +end + +function MOOSE_BRIDGE:_Connect() + local now = mission_time() or 0 + if now - self.LastConnectAttempt < self.ConnectRetryDelay then return end + self.LastConnectAttempt = now + local socket_lib = require("socket") + local sock = socket_lib.tcp() + sock:settimeout(1) + local ok, err = sock:connect(self.Host, self.Port) + if not ok then self:_Log("Connect failed: " .. safe_tostring(err)); sock:close(); return end + sock:settimeout(0) + self.Socket = sock + self.Connected = true + self:_Log("Connected to Python bridge") +end + +function MOOSE_BRIDGE:_Disconnect(reason) + if reason then self:_Log("Disconnected: " .. safe_tostring(reason)) end + if self.Socket then self.Socket:close(); self.Socket = nil end + self.OutQueue = {} + self.Connected = false +end + +function MOOSE_BRIDGE:_NextId(prefix) + self.Sequence = self.Sequence + 1 + return (prefix or "msg") .. "-" .. tostring(self.Sequence) +end + +function MOOSE_BRIDGE:_NextMarkId() + if not UTILS or not UTILS.GetMarkID then error("MOOSE UTILS.GetMarkID is not available") end + return UTILS.GetMarkID() +end + +function MOOSE_BRIDGE:_BaseMessage(message_type) + return {version=1,type=message_type,id=self:_NextId(message_type),source="dcs",sequence=self.Sequence,mission_time=mission_time(),dcs_time=dcs_time(),mission_date=self.MissionDate,wall_time=wall_time()} +end + +local function ammo_number(value) + if type(value) == "number" then return value end + return nil +end + +local function ammo_weapon_id(desc) + if type(desc) ~= "table" then return "unknown" end + if desc.typeName then return tostring(desc.typeName) end + if desc.displayName then return tostring(desc.displayName) end + return table.concat({ + tostring(desc.category or ""), + tostring(desc.missileCategory or ""), + tostring(desc.guidance or ""), + }, ":") +end + +local function detailed_ammo_weapon(item) + local desc = type(item) == "table" and type(item.desc) == "table" and item.desc or {} + local warhead = type(desc.warhead) == "table" and desc.warhead or {} + return { + id=ammo_weapon_id(desc), + count=ammo_number(item and item.count) or 0, + category=ammo_number(desc.category), + type_name=string_or_nil(desc.typeName), + display_name=string_or_nil(desc.displayName), + missile_category=ammo_number(desc.missileCategory), + guidance=ammo_number(desc.guidance), + range_min_m=ammo_number(desc.rangeMin), + range_max_alt_min_m=ammo_number(desc.rangeMaxAltMin), + range_max_alt_max_m=ammo_number(desc.rangeMaxAltMax), + distance_min_m=ammo_number(desc.distMin), + distance_max_m=ammo_number(desc.distMax), + altitude_min_m=ammo_number(desc.altMin), + altitude_max_m=ammo_number(desc.altMax), + warhead_type=ammo_number(warhead.type), + caliber=ammo_number(warhead.caliber), + warhead_mass=ammo_number(warhead.mass), + explosive_mass=ammo_number(warhead.explosiveMass), + shaped_explosive_mass=ammo_number(warhead.shapedExplosiveMass), + shaped_explosive_armor_thickness=ammo_number(warhead.shapedExplosiveArmorThickness), + } +end + +local function ammo_safe_call(object, method_name) + if not object then return nil end + local ok_method, method = pcall(function() return object[method_name] end) + if not ok_method or type(method) ~= "function" then return nil end + local ok, value = pcall(function() return method(object) end) + if ok then return value end + return nil +end + +local function detailed_unit_ammunition(unit) + local dcs_unit = ammo_safe_call(unit, "GetDCSObject") + if not dcs_unit then return nil end + local ok_ammo, ammo = pcall(function() return dcs_unit:getAmmo() end) + if not ok_ammo then error(ammo) end + local ok_desc, unit_desc = pcall(function() return dcs_unit:getDesc() end) + if not ok_desc or type(unit_desc) ~= "table" then unit_desc = {} end + + local attributes = {} + if type(unit_desc.attributes) == "table" then + for name, enabled in pairs(unit_desc.attributes) do + if enabled then attributes[#attributes + 1] = tostring(name) end + end + table.sort(attributes) + end + + local by_id = {} + if type(ammo) == "table" then + for _, item in pairs(ammo) do + local weapon = detailed_ammo_weapon(item) + local existing = by_id[weapon.id] + if existing then + existing.count = existing.count + weapon.count + else + by_id[weapon.id] = weapon + end + end + end + + local weapons = {} + for _, weapon in pairs(by_id) do weapons[#weapons + 1] = weapon end + table.sort(weapons, function(a, b) return a.id < b.id end) + return { + unit_name=ammo_safe_call(unit, "GetName"), + type_name=ammo_safe_call(unit, "GetTypeName") or string_or_nil(unit_desc.typeName), + attributes=attributes, + life=ammo_safe_call(unit, "GetLife") or ammo_safe_call(dcs_unit, "getLife"), + life0=ammo_safe_call(unit, "GetLife0") or ammo_safe_call(dcs_unit, "getLife0"), + weapons=weapons, + } +end + +if UNIT and not UNIT.GetAmmoDetailed then + --- Get compact, descriptor-preserving ammunition data for this unit. + -- @param #UNIT self + -- @return #table Detailed ammunition data, or nil if the DCS unit is unavailable. + function UNIT:GetAmmoDetailed() + return detailed_unit_ammunition(self) + end +end + +if GROUP and not GROUP.GetAmmoDetailed then + --- Get detailed ammunition data for every available unit in this group. + -- @param #GROUP self + -- @return #table Group ammunition data. + function GROUP:GetAmmoDetailed() + local result = {group_name=self:GetName(), units={}} + local units = self:GetUnits() + if type(units) ~= "table" then return result end + for _, unit in pairs(units) do + local data = unit and unit.GetAmmoDetailed and unit:GetAmmoDetailed() or nil + if data then result.units[#result.units + 1] = data end + end + table.sort(result.units, function(a, b) return tostring(a.unit_name) < tostring(b.unit_name) end) + return result + end +end + +function MOOSE_BRIDGE:Send(message) + if not self.Socket then + return self + end + self.OutQueue[#self.OutQueue + 1] = json.encode(message) + return self +end + +function MOOSE_BRIDGE:SendHeartbeat() + local msg = self:_BaseMessage("heartbeat") + msg.status = "running" + self:Send(msg) +end + +function MOOSE_BRIDGE:SendSnapshot(kind, payload) + local msg = self:_BaseMessage("snapshot") + msg.kind = kind + msg.payload = payload or {} + self:Send(msg) +end + +function MOOSE_BRIDGE:SendEvent(event_name, payload) + local msg = self:_BaseMessage("event") + msg.event = event_name + msg.payload = payload or {} + if type(msg.payload) == "table" then msg.payload.event = event_name end + self:Send(msg) +end + +function MOOSE_BRIDGE:SendAck(command, ok, result, error_message) + local msg = self:_BaseMessage("ack") + msg.correlation_id = command and command.id or nil + msg.ok = ok and true or false + msg.result = result + msg.error = error_message + self:Send(msg) +end + +function MOOSE_BRIDGE:RegisterCommand(action, handler) + self.CommandHandlers[action] = handler + return self +end + +function MOOSE_BRIDGE:RegisterZone(zone, name) + if not zone then return self end + local zone_name = name or self:_SafeCall(zone, "GetName") or zone.ZoneName or zone.name + if zone_name then self.RegisteredZones[safe_tostring(zone_name)] = zone end + return self +end + +function MOOSE_BRIDGE:RegisterZones(zones) + if type(zones) ~= "table" then return self end + for name, zone in pairs(zones) do self:RegisterZone(zone, name) end + return self +end + +function MOOSE_BRIDGE:RegisterOpsZone(opszone, name) + if not opszone then return self end + local zone_name = name or self:_SafeCall(opszone, "GetName") or opszone.Name or opszone.name + if zone_name then + zone_name = safe_tostring(zone_name) + self.RegisteredOpsZones[zone_name] = opszone + if self._AttachOpsZoneEventForwarder then self:_AttachOpsZoneEventForwarder(opszone, zone_name) end + end + return self +end + +function MOOSE_BRIDGE:RegisterOpsZones(opszones) + if type(opszones) ~= "table" then return self end + for name, opszone in pairs(opszones) do self:RegisterOpsZone(opszone, name) end + return self +end + +function MOOSE_BRIDGE:RegisterOpsGroup(opsgroup, name) + if not opsgroup then return self end + local group_name = name or self:_SafeCall(opsgroup, "GetName") or opsgroup.Name or opsgroup.name + if group_name then self.RegisteredOpsGroups[safe_tostring(group_name)] = opsgroup end + return self +end + +function MOOSE_BRIDGE:RegisterOpsGroups(opsgroups) + if type(opsgroups) ~= "table" then return self end + for name, opsgroup in pairs(opsgroups) do self:RegisterOpsGroup(opsgroup, name) end + return self +end + +function MOOSE_BRIDGE:RegisterCommander(commander, name) + if not commander then return self end + local commander_name = name or commander.alias or self:_SafeCall(commander, "GetName") + if commander_name then self.RegisteredCommanders[safe_tostring(commander_name)] = commander end + return self +end + +function MOOSE_BRIDGE:RegisterCommanders(commanders) + if type(commanders) ~= "table" then return self end + for name, commander in pairs(commanders) do self:RegisterCommander(commander, name) end + return self +end + +function MOOSE_BRIDGE:_SafeCall(object, method_name) + if not object or not method_name then return nil end + local ok_method, method = pcall(function() return object[method_name] end) + if not ok_method or not method then return nil end + local ok, value = pcall(function() return method(object) end) + if ok then return value end + return nil +end + +function MOOSE_BRIDGE:_SafeCallArg(object, method_name, ...) + if not object or not method_name then return nil end + local ok_method, method = pcall(function() return object[method_name] end) + if not ok_method or not method then return nil end + local args = {...} + local ok, value = pcall(function() return method(object, unpack(args)) end) + if ok then return value end + return nil +end + +function MOOSE_BRIDGE:_DcsCall(object, method_name) + if not object or not method_name then return nil end + local ok, value = pcall(function() return object[method_name](object) end) + if ok then return value end + return nil +end + +function MOOSE_BRIDGE:_ObjectName(object) + if not object then return nil end + local name = self:_SafeCall(object, "GetName") + if name then return safe_tostring(name) end + if object.alias then return safe_tostring(object.alias) end + if object.name then return safe_tostring(object.name) end + if object.Name then return safe_tostring(object.Name) end + if object.groupname then return safe_tostring(object.groupname) end + return nil +end + +function MOOSE_BRIDGE:_CoalitionToName(value) + if value == nil then return nil end + if coalition and coalition.side then + if value == coalition.side.BLUE then return "blue" end + if value == coalition.side.RED then return "red" end + if value == coalition.side.NEUTRAL then return "neutral" end + end + if value == 2 then return "blue" end + if value == 1 then return "red" end + if value == 0 then return "neutral" end + return tostring(value) +end + +function MOOSE_BRIDGE:_AirbaseCategoryToName(value) + if value == nil then return nil end + if Airbase and Airbase.Category then + if value == Airbase.Category.AIRDROME then return "Airdrome" end + if value == Airbase.Category.HELIPAD then return "Heliport" end + if value == Airbase.Category.SHIP then return "Ship" end + end + if value == 0 then return "Airdrome" end + if value == 1 then return "Heliport" end + if value == 2 then return "Ship" end + return "Unknown " .. tostring(value) +end + +function MOOSE_BRIDGE:_BoolOrFalse(value) + if value == nil then return false end + return value and true or false +end + +function MOOSE_BRIDGE:_NumberOrZero(value) + if type(value) == "number" then return value end + return 0 +end + +function MOOSE_BRIDGE:_NumberOrNil(value) + if type(value) == "number" then return value end + if type(value) == "string" then return tonumber(value) end + return nil +end + +function MOOSE_BRIDGE:_IsDcsObjectAlive(object) + if not object then return false end + local ok_exist, exists = pcall(function() return object:isExist() end) + if ok_exist and not exists then return false end + local ok_life, life = pcall(function() return object:getLife() end) + if ok_life and type(life) == "number" then return life > 0 end + return true +end + +function MOOSE_BRIDGE:_DcsTypeName(object) + return self:_DcsCall(object, "getTypeName") +end + +function MOOSE_BRIDGE:_DcsPoint(object) + return self:_DcsCall(object, "getPoint") +end + +function MOOSE_BRIDGE:_PointFromMooseObject(object) + if not object then return nil end + local coordinate = self:_SafeCall(object, "GetCoordinate") + if coordinate then + local vec3 = self:_SafeCall(coordinate, "GetVec3") + if vec3 then return vec3 end + end + local vec3 = self:_SafeCall(object, "GetVec3") or self:_SafeCall(object, "GetPointVec3") + if vec3 then return vec3 end + if object.Coordinate then + vec3 = self:_SafeCall(object.Coordinate, "GetVec3") + if vec3 then return vec3 end + end + if object.position then return object.position end + return nil +end + +function MOOSE_BRIDGE:_PointFromParams(params) + local x = self:_NumberOrNil(params and params.x) + local y = self:_NumberOrNil(params and params.y) or 0 + local z = self:_NumberOrNil(params and params.z) + if x == nil or z == nil then error("Point commands require numeric x and z parameters") end + return {x=x, y=y, z=z} +end + +function MOOSE_BRIDGE:_SplitObjectId(object_id) + if type(object_id) ~= "string" then return nil, nil end + local separator = string.find(object_id, ":") + if not separator then return nil, nil end + return string.sub(object_id, 1, separator - 1), string.sub(object_id, separator + 1) +end + +function MOOSE_BRIDGE:_PointForGroupName(name) + local group = _DATABASE and _DATABASE.GROUPS and _DATABASE.GROUPS[name] + if not group then return nil end + local point = self:_PointFromMooseObject(group) + if point then return point end + local dcs_group = self:_SafeCall(group, "GetDCSObject") + local ok, units = pcall(function() return dcs_group and dcs_group:getUnits() end) + if ok and type(units) == "table" and units[1] then return self:_DcsPoint(units[1]) end + return nil +end + +function MOOSE_BRIDGE:_PointForUnitName(name) + local unit = _DATABASE and _DATABASE.UNITS and _DATABASE.UNITS[name] + if not unit then return nil end + local dcs_unit = self:_SafeCall(unit, "GetDCSObject") + return self:_DcsPoint(dcs_unit) or self:_PointFromMooseObject(unit) +end + +function MOOSE_BRIDGE:_PointForStaticName(name) + local static = _DATABASE and _DATABASE.STATICS and _DATABASE.STATICS[name] + if not static then return nil end + local dcs_static = self:_SafeCall(static, "GetDCSObject") + return self:_DcsPoint(dcs_static) or self:_PointFromMooseObject(static) +end + +function MOOSE_BRIDGE:_PointForAirbaseName(name) + local airbase = _DATABASE and _DATABASE.AIRBASES and _DATABASE.AIRBASES[name] + return self:_PointFromMooseObject(airbase) +end + +function MOOSE_BRIDGE:_PointForOpsZoneName(name) + local opszone = self.RegisteredOpsZones and self.RegisteredOpsZones[name] + if not opszone and _DATABASE and type(_DATABASE.OPSZONES) == "table" then opszone = _DATABASE.OPSZONES[name] end + if not opszone then return nil end + return self:_PointFromMooseObject(opszone) +end + +function MOOSE_BRIDGE:_TerritoryForName(name) + if not _DATABASE then return nil end + local territory = self:_SafeCallArg(_DATABASE, "FindTerritory", name) + if not territory and type(_DATABASE.TERRITORIES) == "table" then territory = _DATABASE.TERRITORIES[name] end + return territory +end + +function MOOSE_BRIDGE:_PointForTerritoryName(name) + local territory = self:_TerritoryForName(name) + if not territory then return nil end + return self:_PointFromMooseObject(territory) +end + +function MOOSE_BRIDGE:_PointForZoneName(name) + local zone = self.RegisteredZones and self.RegisteredZones[name] + if not zone and _DATABASE and _DATABASE.ZONES then zone = _DATABASE.ZONES[name] end + if zone then + local point = self:_PointFromMooseObject(zone) + if point then return point end + end + local opszone_point = self:_PointForOpsZoneName(name) + if opszone_point then return opszone_point end + if env and env.mission and env.mission.triggers and type(env.mission.triggers.zones) == "table" then + for _, trigger_zone in pairs(env.mission.triggers.zones) do + if trigger_zone.name == name then return {x=trigger_zone.x, y=0, z=trigger_zone.y} end + end + end + return nil +end + +function MOOSE_BRIDGE:_PointForObjectId(object_id) + local object_type, name = self:_SplitObjectId(object_id) + if not object_type or not name then error("Invalid object_id: " .. safe_tostring(object_id)) end + if object_type == "GROUP" then return self:_PointForGroupName(name) end + if object_type == "UNIT" then return self:_PointForUnitName(name) end + if object_type == "STATIC" then return self:_PointForStaticName(name) end + if object_type == "AIRBASE" then return self:_PointForAirbaseName(name) end + if object_type == "ZONE" then return self:_PointForZoneName(name) end + if object_type == "OPSZONE" then return self:_PointForOpsZoneName(name) end + if object_type == "TERRITORY" then return self:_PointForTerritoryName(name) end + error("Unsupported object_id type for point lookup: " .. safe_tostring(object_type)) +end + +function MOOSE_BRIDGE:_DrawZoneCoalition(value) + if value == nil or value == "" then return -1 end + if type(value) == "number" then return value end + local normalized = string.lower(tostring(value)) + if normalized == "all" then return -1 end + if normalized == "neutral" then return 0 end + if normalized == "red" then return 1 end + if normalized == "blue" then return 2 end + local numeric = tonumber(value) + if numeric ~= nil then return numeric end + error("Unknown draw zone coalition: " .. safe_tostring(value)) +end + +function MOOSE_BRIDGE:_DrawZoneColor(value) + if value == nil or value == "" then return nil end + local normalized = string.lower(tostring(value)) + local colors = { + red={1,0,0}, + green={0,1,0}, + blue={0,0,1}, + yellow={1,1,0}, + orange={1,0.5,0}, + white={1,1,1}, + black={0,0,0}, + grey={0.5,0.5,0.5}, + gray={0.5,0.5,0.5}, + } + local color = colors[normalized] + if color then return color end + error("Unsupported draw zone color: " .. safe_tostring(value)) +end + +function MOOSE_BRIDGE:_DrawZoneLineType(value) + if value == nil or value == "" then return nil end + if type(value) == "number" then return value end + local normalized = string.lower(tostring(value)):gsub("[%s_-]", "") + local line_types = {none=0, solid=1, dashed=2, dotted=3, dotdash=4, longdash=5, twodash=6} + if line_types[normalized] ~= nil then return line_types[normalized] end + local numeric = tonumber(value) + if numeric ~= nil then return numeric end + error("Unsupported draw zone line_type: " .. safe_tostring(value)) +end + +function MOOSE_BRIDGE:_OptionalString(value) + if value == nil or value == "" then return nil end + return tostring(value) +end + +function MOOSE_BRIDGE:_NormalizeCoordinateFormat(value) + if value == nil or value == "" then return "xyz" end + local normalized = string.lower(tostring(value)) + if normalized == "xyz" then return "xyz" end + if normalized == "ll" or normalized == "latlon" or normalized == "latlong" or normalized == "latitude" then return "ll" end + if normalized == "mgrs" then return "mgrs" end + if normalized == "all" then return "all" end + error("Unsupported coordinate format: " .. safe_tostring(value)) +end + +function MOOSE_BRIDGE:_MGRSToString(mgrs) + if type(mgrs) ~= "table" then return nil end + local zone = mgrs.UTMZone or mgrs.utmZone or mgrs.zone + local digraph = mgrs.MGRSDigraph or mgrs.mgrsDigraph or mgrs.digraph + local easting = mgrs.Easting or mgrs.easting + local northing = mgrs.Northing or mgrs.northing + if not zone or not digraph or easting == nil or northing == nil then return nil end + return string.format("%s %s %05d %05d", tostring(zone), tostring(digraph), math.floor(easting + 0.5), math.floor(northing + 0.5)) +end + +function MOOSE_BRIDGE:_CoordinatesForPoint(point, format) + if not point then error("Point is nil") end + local normalized = self:_NormalizeCoordinateFormat(format) + local result = {format=normalized, x=point.x, y=point.y or 0, z=point.z} + + if normalized == "ll" or normalized == "mgrs" or normalized == "all" then + if not coord or not coord.LOtoLL then error("DCS coord.LOtoLL is not available") end + local latitude, longitude = coord.LOtoLL({x=point.x, y=point.y or 0, z=point.z}) + result.latitude = latitude + result.longitude = longitude + result.altitude = point.y or 0 + end + + if normalized == "mgrs" or normalized == "all" then + if not coord or not coord.LLtoMGRS then error("DCS coord.LLtoMGRS is not available") end + local mgrs = coord.LLtoMGRS(result.latitude, result.longitude) + result.mgrs = self:_MGRSToString(mgrs) + result.mgrs_zone = mgrs and (mgrs.UTMZone or mgrs.utmZone or mgrs.zone) or nil + result.mgrs_digraph = mgrs and (mgrs.MGRSDigraph or mgrs.mgrsDigraph or mgrs.digraph) or nil + result.mgrs_easting = mgrs and (mgrs.Easting or mgrs.easting) or nil + result.mgrs_northing = mgrs and (mgrs.Northing or mgrs.northing) or nil + end + + return result +end + +function MOOSE_BRIDGE:_AddPointFields(item, point) + if type(item) ~= "table" or type(point) ~= "table" then return item end + local coordinates = self:_CoordinatesForPoint(point, "ll") + item.x = coordinates.x + item.y = coordinates.y + item.z = coordinates.z + item.latitude = coordinates.latitude + item.longitude = coordinates.longitude + return item +end + +function MOOSE_BRIDGE:_DistanceBetweenPoints(point_a, point_b) + if not point_a or not point_b then error("Distance requires two points") end + local dx = (point_b.x or 0) - (point_a.x or 0) + local dy = (point_b.y or 0) - (point_a.y or 0) + local dz = (point_b.z or 0) - (point_a.z or 0) + return math.sqrt(dx * dx + dy * dy + dz * dz) +end + +function MOOSE_BRIDGE:_ZoneForDrawObjectId(object_id) + local object_type, name = self:_SplitObjectId(object_id) + if not object_type or not name then error("Invalid zone object_id: " .. safe_tostring(object_id)) end + + local zone = nil + if object_type == "ZONE" then + zone = self.RegisteredZones and self.RegisteredZones[name] + if not zone and _DATABASE and _DATABASE.ZONES then zone = _DATABASE.ZONES[name] end + if not zone and ZONE and ZONE.FindByName then zone = ZONE:FindByName(name) end + if not zone and ZONE and ZONE.New then + local ok, created = pcall(function() return ZONE:New(name) end) + if ok then zone = created end + end + elseif object_type == "OPSZONE" then + local opszone = self.RegisteredOpsZones and self.RegisteredOpsZones[name] + if not opszone and _DATABASE and type(_DATABASE.OPSZONES) == "table" then opszone = _DATABASE.OPSZONES[name] end + zone = self:_SafeCall(opszone, "GetZone") or opszone and (opszone.zone or opszone.Zone or opszone.ZONE) or opszone + elseif object_type == "TERRITORY" then + local territory = self:_TerritoryForName(name) + zone = self:_SafeCall(territory, "GetZone") or territory and territory.zone + else + error("DrawZone requires ZONE:, OPSZONE:, or TERRITORY:, got " .. safe_tostring(object_type)) + end + + if not zone then error("Zone not found: " .. safe_tostring(object_id)) end + if not zone.DrawZone then error("Zone does not support DrawZone: " .. safe_tostring(object_id)) end + return zone, name, object_type +end + +function MOOSE_BRIDGE:_CoordinateFromPoint(point) + if not COORDINATE or not COORDINATE.NewFromVec3 then error("MOOSE COORDINATE is not available") end + if not point then error("Point is nil") end + return COORDINATE:NewFromVec3({x=point.x, y=point.y or 0, z=point.z}) +end + +function MOOSE_BRIDGE:_SmokePoint(point, color) + local coordinate = self:_CoordinateFromPoint(point) + local smoke_color = string.lower(color or "white") + local method_by_color = {red="SmokeRed", green="SmokeGreen", blue="SmokeBlue", orange="SmokeOrange", white="SmokeWhite"} + local method_name = method_by_color[smoke_color] + if not method_name then error("Unsupported smoke color: " .. safe_tostring(color)) end + local method = coordinate[method_name] + if not method then error("COORDINATE method unavailable: " .. method_name) end + method(coordinate) + return {x=point.x, y=point.y or 0, z=point.z, color=smoke_color} +end + +function MOOSE_BRIDGE:_ExplosionPoint(point, power, delay) + local explosion_power = self:_NumberOrNil(power) + local explosion_delay = self:_NumberOrNil(delay) or 0 + if explosion_power == nil or explosion_power <= 0 then error("Explosion power must be a positive number in kg TNT") end + if explosion_delay < 0 then error("Explosion delay must be zero or greater") end + + local coordinate = self:_CoordinateFromPoint(point) + if not coordinate.Explosion then error("COORDINATE:Explosion is not available") end + coordinate:Explosion(explosion_power, explosion_delay) + return { + x=point.x, + y=point.y or 0, + z=point.z, + power_kg_tnt=explosion_power, + delay_seconds=explosion_delay, + } +end + +function MOOSE_BRIDGE:_MarkPoint(point, text) + local coordinate = self:_CoordinateFromPoint(point) + local mark_text = text or "MOOSE Bridge mark" + if coordinate.MarkToAll then + coordinate:MarkToAll(mark_text) + elseif trigger and trigger.action and trigger.action.markToAll then + trigger.action.markToAll(self:_NextMarkId(), mark_text, {x=point.x, y=point.y or 0, z=point.z}, true) + else + error("No mark implementation available") + end + return {x=point.x, y=point.y or 0, z=point.z, text=mark_text} +end + +function MOOSE_BRIDGE:_CountTable(value) + if type(value) ~= "table" then return 0 end + local count = 0 + for _, _ in pairs(value) do count = count + 1 end + return count +end + +function MOOSE_BRIDGE:_CountSet(set_object) + if not set_object then return 0 end + local count = self:_SafeCall(set_object, "Count") or self:_SafeCall(set_object, "CountAlive") + if type(count) == "number" then return count end + if type(set_object.Set) == "table" then return self:_CountTable(set_object.Set) end + return 0 +end + +function MOOSE_BRIDGE:_CountUnitsInTable(units, alive_only) + if type(units) ~= "table" then return nil end + local count = 0 + for _, unit in pairs(units) do + if alive_only then + if self:_IsMooseUnitAlive(unit) then count = count + 1 end + else + count = count + 1 + end + end + return count +end + +function MOOSE_BRIDGE:_IsMooseUnitAlive(unit) + if not unit then return false end + local alive = self:_SafeCall(unit, "IsAlive") + if alive ~= nil then return alive and true or false end + local dcs_unit = self:_SafeCall(unit, "GetDCSObject") + if dcs_unit then return self:_IsDcsObjectAlive(dcs_unit) end + return false +end + +function MOOSE_BRIDGE:_CountDcsGroupUnits(group, alive_only) + local dcs_group = self:_SafeCall(group, "GetDCSObject") + if not dcs_group then return nil end + local ok, units = pcall(function() return dcs_group:getUnits() end) + if not ok or type(units) ~= "table" then return nil end + local count = 0 + for _, unit in pairs(units) do + if alive_only then + if self:_IsDcsObjectAlive(unit) then count = count + 1 end + else + count = count + 1 + end + end + return count +end + +function MOOSE_BRIDGE:_CountGroupUnits(group, alive_only) + local units = self:_SafeCall(group, "GetUnits") + local count = self:_CountUnitsInTable(units, alive_only) + if count ~= nil then return count end + count = self:_CountDcsGroupUnits(group, alive_only) + if count ~= nil then return count end + if alive_only then count = self:_SafeCall(group, "CountAliveUnits") else count = self:_SafeCall(group, "CountUnits") end + return self:_NumberOrZero(count) +end + +function MOOSE_BRIDGE:_BuildGroupSnapshotItem(group_name, group) + local name = self:_SafeCall(group, "GetName") or group_name + local coalition_value = self:_SafeCall(group, "GetCoalition") + local category = self:_SafeCall(group, "GetCategoryName") or self:_SafeCall(group, "GetCategory") + local alive = self:_SafeCall(group, "IsAlive") + local active = self:_SafeCall(group, "IsActive") + local unit_count = self:_CountGroupUnits(group, false) + local alive_unit_count = self:_CountGroupUnits(group, true) + local point = self:_PointForGroupName(name) + local item = {object_id="GROUP:"..safe_tostring(name),dcs_name=safe_tostring(name),object_type="GROUP",category=category and safe_tostring(category) or nil,coalition=self:_CoalitionToName(coalition_value),alive=self:_BoolOrFalse(alive),active=self:_BoolOrFalse(active),unit_count=self:_NumberOrZero(unit_count),alive_unit_count=self:_NumberOrZero(alive_unit_count)} + if point then self:_AddPointFields(item, point) end + return item +end + +function MOOSE_BRIDGE:_DebugMarkupCoalition(value) + return self:_DrawZoneCoalition(value) +end + +function MOOSE_BRIDGE:_DebugMarkupColor(value, default) + local color = value or default + if type(color) ~= "table" or (#color ~= 3 and #color ~= 4) then + error("Markup color must contain RGB or RGBA values") + end + local result = {} + for index = 1, 4 do + local component = color[index] + if component == nil and index == 4 then component = 1 end + component = tonumber(component) + if component == nil or component < 0 or component > 1 then + error("Markup color components must be in range 0..1") + end + result[index] = component + end + return result +end + +function MOOSE_BRIDGE:_DebugMarkupPoint(value) + if type(value) ~= "table" then error("Markup point must be a table") end + if type(value.latitude) == "number" and type(value.longitude) == "number" then + if not coord or not coord.LLtoLO then error("DCS coord.LLtoLO is not available") end + local point = coord.LLtoLO(value.latitude, value.longitude, tonumber(value.altitude) or 0) + if not point then error("DCS could not convert markup latitude/longitude") end + return {x=point.x, y=point.y or 0, z=point.z} + end + if type(value.x) == "number" and type(value.z) == "number" then + return {x=value.x, y=tonumber(value.y) or 0, z=value.z} + end + error("Markup point requires latitude/longitude or x/z") +end + +function MOOSE_BRIDGE:_RemoveDebugMarkIds(ids) + if type(ids) ~= "table" or not trigger or not trigger.action or not trigger.action.removeMark then return 0 end + local removed = 0 + for _, mark_id in ipairs(ids) do + local ok = pcall(function() trigger.action.removeMark(mark_id) end) + if ok then removed = removed + 1 end + end + return removed +end + +function MOOSE_BRIDGE:_ClearDebugOverlay(overlay_id) + local key = tostring(overlay_id or "") + local ids = self.DebugOverlays and self.DebugOverlays[key] + local removed = self:_RemoveDebugMarkIds(ids) + if self.DebugOverlays then self.DebugOverlays[key] = nil end + return removed +end + +function MOOSE_BRIDGE:_ClearDebugOverlays() + local removed = 0 + for overlay_id, _ in pairs(self.DebugOverlays or {}) do + removed = removed + self:_ClearDebugOverlay(overlay_id) + end + return removed +end + +function MOOSE_BRIDGE:_DrawDebugOverlay(params) + if not trigger or not trigger.action or not trigger.action.lineToAll or not trigger.action.circleToAll then + error("DCS trigger.action.lineToAll/circleToAll is not available") + end + local overlay_id = self:_OptionalString(params.overlay_id) + if not overlay_id or overlay_id == "" then error("map.overlay.draw requires overlay_id") end + if #overlay_id > 96 then error("overlay_id accepts at most 96 characters") end + local features = params.features + if type(features) ~= "table" or #features == 0 then error("map.overlay.draw requires features") end + if #features > 200 then error("map.overlay.draw accepts at most 200 features") end + + local coalition_id = self:_DebugMarkupCoalition(params.coalition or "all") + local line_type = self:_DrawZoneLineType(params.line_type) or 1 + local read_only = params.read_only ~= false + local normalized = {} + local point_count = 0 + local mark_count = 0 + local bounds = nil + for index, feature in ipairs(features) do + if type(feature) ~= "table" then error("Invalid markup feature at index " .. safe_tostring(index)) end + local kind = string.lower(tostring(feature.kind or "")) + if kind ~= "point" and kind ~= "line" and kind ~= "polygon" then + error("Unsupported markup kind at index " .. safe_tostring(index) .. ": " .. safe_tostring(kind)) + end + local points = feature.points + if type(points) ~= "table" then error("Markup feature points must be a table at index " .. safe_tostring(index)) end + local minimum = kind == "point" and 1 or kind == "line" and 2 or 3 + if #points < minimum then error("Markup feature has too few points at index " .. safe_tostring(index)) end + local converted = {} + for _, point in ipairs(points) do + local converted_point = self:_DebugMarkupPoint(point) + converted[#converted + 1] = converted_point + if not bounds then + bounds = {min_x=converted_point.x, max_x=converted_point.x, min_z=converted_point.z, max_z=converted_point.z} + else + bounds.min_x = math.min(bounds.min_x, converted_point.x) + bounds.max_x = math.max(bounds.max_x, converted_point.x) + bounds.min_z = math.min(bounds.min_z, converted_point.z) + bounds.max_z = math.max(bounds.max_z, converted_point.z) + end + end + point_count = point_count + #converted + local feature_marks = kind == "point" and 1 or (#converted - 1) + if kind == "polygon" then + local first, last = converted[1], converted[#converted] + if first.x ~= last.x or first.z ~= last.z then feature_marks = feature_marks + 1 end + end + mark_count = mark_count + feature_marks + normalized[#normalized + 1] = { + kind=kind, + points=converted, + radius=tonumber(feature.radius_m) or 100, + color=self:_DebugMarkupColor(feature.color, {0,1,0,1}), + fill_color=self:_DebugMarkupColor(feature.fill_color, {0,1,0,0.12}), + line_type=self:_DrawZoneLineType(feature.line_type) or line_type, + } + end + if point_count > 2000 then error("map.overlay.draw accepts at most 2000 points") end + if mark_count > 500 then error("map.overlay.draw would create more than 500 DCS markups") end + + if params.replace ~= false then self:_ClearDebugOverlay(overlay_id) end + if self.DebugOverlays[overlay_id] then error("Debug overlay already exists: " .. overlay_id) end + local ids = {} + local function draw(method, ...) + local mark_id = self:_NextMarkId() + local arguments = {coalition_id, mark_id} + local values = {...} + for _, value in ipairs(values) do arguments[#arguments + 1] = value end + local ok, err = pcall(function() method(unpack(arguments)) end) + if not ok then error(err) end + ids[#ids + 1] = mark_id + end + local ok, draw_error = pcall(function() + for _, feature in ipairs(normalized) do + if feature.kind == "point" then + draw(trigger.action.circleToAll, feature.points[1], feature.radius, feature.color, feature.fill_color, feature.line_type, read_only, "") + else + for point_index = 1, #feature.points - 1 do + draw(trigger.action.lineToAll, feature.points[point_index], feature.points[point_index + 1], feature.color, feature.line_type, read_only, "") + end + if feature.kind == "polygon" then + local first, last = feature.points[1], feature.points[#feature.points] + if first.x ~= last.x or first.z ~= last.z then + draw(trigger.action.lineToAll, last, first, feature.color, feature.line_type, read_only, "") + end + end + end + end + end) + if not ok then + self:_RemoveDebugMarkIds(ids) + error(draw_error) + end + self.DebugOverlays[overlay_id] = ids + return {action="map.overlay.draw", overlay_id=overlay_id, feature_count=#normalized, point_count=point_count, mark_count=#ids, coalition=coalition_id, dcs_bounds=bounds} +end + +function MOOSE_BRIDGE:BuildGroupSnapshot() + local result = {} + if not _DATABASE or not _DATABASE.GROUPS then return result end + for group_name, group in pairs(_DATABASE.GROUPS) do + local ok, item = pcall(function() return self:_BuildGroupSnapshotItem(group_name, group) end) + if ok and item then result[#result + 1] = item else self:_Log("Failed to snapshot group " .. safe_tostring(group_name) .. ": " .. safe_tostring(item)) end + end + return result +end + +function MOOSE_BRIDGE:_BuildUnitSnapshotItem(unit_name, unit) + local name = self:_SafeCall(unit, "GetName") or unit_name + local group_name = self:_SafeCall(unit, "GetGroupName") + local group = self:_SafeCall(unit, "GetGroup") + if not group_name and group then group_name = self:_SafeCall(group, "GetName") end + local coalition_value = self:_SafeCall(unit, "GetCoalition") + if coalition_value == nil and group then coalition_value = self:_SafeCall(group, "GetCoalition") end + local category = self:_SafeCall(unit, "GetCategoryName") or self:_SafeCall(unit, "GetCategory") + if not category and group then category = self:_SafeCall(group, "GetCategoryName") or self:_SafeCall(group, "GetCategory") end + local dcs_unit = self:_SafeCall(unit, "GetDCSObject") + local dcs_type = self:_SafeCall(unit, "GetTypeName") or self:_DcsTypeName(dcs_unit) + local alive = self:_SafeCall(unit, "IsAlive") + if alive == nil then alive = self:_IsDcsObjectAlive(dcs_unit) end + local active = self:_SafeCall(unit, "IsActive") + local point = self:_DcsPoint(dcs_unit) + local item = {object_id="UNIT:"..safe_tostring(name),dcs_name=safe_tostring(name),object_type="UNIT",group_name=group_name and safe_tostring(group_name) or nil,category=category and safe_tostring(category) or nil,coalition=self:_CoalitionToName(coalition_value),dcs_type=dcs_type and safe_tostring(dcs_type) or nil,alive=self:_BoolOrFalse(alive),active=self:_BoolOrFalse(active)} + if point then self:_AddPointFields(item, point) end + return item +end + +function MOOSE_BRIDGE:BuildUnitSnapshot() + local result = {} + if _DATABASE and _DATABASE.UNITS then + for unit_name, unit in pairs(_DATABASE.UNITS) do + local ok, item = pcall(function() return self:_BuildUnitSnapshotItem(unit_name, unit) end) + if ok and item then result[#result + 1] = item else self:_Log("Failed to snapshot unit " .. safe_tostring(unit_name) .. ": " .. safe_tostring(item)) end + end + return result + end + if not _DATABASE or not _DATABASE.GROUPS then return result end + for _, group in pairs(_DATABASE.GROUPS) do + local units = self:_SafeCall(group, "GetUnits") + if type(units) == "table" then + for unit_name, unit in pairs(units) do + local ok, item = pcall(function() return self:_BuildUnitSnapshotItem(unit_name, unit) end) + if ok and item then result[#result + 1] = item else self:_Log("Failed to snapshot group unit " .. safe_tostring(unit_name) .. ": " .. safe_tostring(item)) end + end + end + end + return result +end + +function MOOSE_BRIDGE:_BuildStaticSnapshotItem(static_name, static) + local name = self:_SafeCall(static, "GetName") or static_name + local coalition_value = self:_SafeCall(static, "GetCoalition") + local category = self:_SafeCall(static, "GetCategoryName") or self:_SafeCall(static, "GetCategory") + local dcs_static = self:_SafeCall(static, "GetDCSObject") + local dcs_type = self:_SafeCall(static, "GetTypeName") or self:_DcsTypeName(dcs_static) + local alive = self:_SafeCall(static, "IsAlive") + if alive == nil then alive = self:_IsDcsObjectAlive(dcs_static) end + local point = self:_DcsPoint(dcs_static) or self:_PointFromMooseObject(static) + local item = {object_id="STATIC:"..safe_tostring(name),dcs_name=safe_tostring(name),object_type="STATIC",category=category and safe_tostring(category) or "STATIC",coalition=self:_CoalitionToName(coalition_value),dcs_type=dcs_type and safe_tostring(dcs_type) or nil,alive=self:_BoolOrFalse(alive)} + if point then self:_AddPointFields(item, point) end + return item +end + +function MOOSE_BRIDGE:BuildStaticSnapshot() + local result = {} + if not _DATABASE or not _DATABASE.STATICS then return result end + for static_name, static in pairs(_DATABASE.STATICS) do + local ok, item = pcall(function() return self:_BuildStaticSnapshotItem(static_name, static) end) + if ok and item then result[#result + 1] = item else self:_Log("Failed to snapshot static " .. safe_tostring(static_name) .. ": " .. safe_tostring(item)) end + end + return result +end + +function MOOSE_BRIDGE:_BuildAirbaseSnapshotItem(airbase_name, airbase) + local name = self:_SafeCall(airbase, "GetName") or airbase.AirbaseName or airbase_name + local coalition_value = self:_SafeCall(airbase, "GetCoalition") + local airbase_category = airbase.category + local object_category_name = airbase.objectcategoryName + local point = self:_PointFromMooseObject(airbase) + local item = {object_id="AIRBASE:"..safe_tostring(name),dcs_name=safe_tostring(name),name=safe_tostring(name),object_type="AIRBASE",category=self:_AirbaseCategoryToName(airbase_category) or "Airbase",type=object_category_name and safe_tostring(object_category_name) or nil,source="database.AIRBASES",airbase_id=self:_NumberOrNil(airbase.AirbaseID),coalition=self:_CoalitionToName(coalition_value)} + if point then self:_AddPointFields(item, point) end + return item +end + +function MOOSE_BRIDGE:BuildAirbaseSnapshot() + local result = {} + if not _DATABASE or type(_DATABASE.AIRBASES) ~= "table" then return result end + for airbase_name, airbase in pairs(_DATABASE.AIRBASES) do + local ok_item, item = pcall(function() return self:_BuildAirbaseSnapshotItem(airbase_name, airbase) end) + if ok_item and item and item.dcs_name then result[#result + 1] = item else self:_Log("Failed to snapshot airbase " .. safe_tostring(airbase_name) .. ": " .. safe_tostring(item)) end + end + return result +end + +function MOOSE_BRIDGE:_BuildAmmunitionSnapshotItem(unit_name, unit) + if not self:_IsMooseUnitAlive(unit) or not self:_BoolOrFalse(self:_SafeCall(unit, "IsActive")) then return nil end + local category = self:_SafeCall(unit, "GetCategoryName") or self:_SafeCall(unit, "GetCategory") + local category_name = category and safe_tostring(category):lower() or "" + local supported_category = category_name:find("ground", 1, true) + or category_name:find("ship", 1, true) + or category_name:find("naval", 1, true) + if not supported_category then return nil end + local details = self:_SafeCall(unit, "GetAmmoDetailed") + if type(details) ~= "table" then + -- MOOSE instances already present in _DATABASE may not see methods added + -- to the UNIT class after their construction. + local ok_details, fallback_details = pcall(function() return detailed_unit_ammunition(unit) end) + if not ok_details then + self:_Log("Failed to read unit ammunition " .. safe_tostring(unit_name) .. ": " .. safe_tostring(fallback_details)) + return nil + end + details = fallback_details + end + if type(details) ~= "table" then return nil end + local name = self:_SafeCall(unit, "GetName") or unit_name + local group_name = self:_SafeCall(unit, "GetGroupName") + local group = self:_SafeCall(unit, "GetGroup") + if not group_name and group then group_name = self:_SafeCall(group, "GetName") end + return { + object_id="UNIT:"..safe_tostring(name), + unit_id="UNIT:"..safe_tostring(name), + unit_name=safe_tostring(name), + group_id=group_name and "GROUP:"..safe_tostring(group_name) or nil, + group_name=group_name and safe_tostring(group_name) or nil, + dcs_type=details.type_name, + category=category and safe_tostring(category) or nil, + attributes=details.attributes or {}, + life=details.life, + life0=details.life0, + weapons=details.weapons or {}, + } +end + +function MOOSE_BRIDGE:BuildAmmunitionSnapshot() + local result = {} + if not _DATABASE or not _DATABASE.UNITS then return result end + for unit_name, unit in pairs(_DATABASE.UNITS) do + local ok, item = pcall(function() return self:_BuildAmmunitionSnapshotItem(unit_name, unit) end) + if ok and item then + result[#result + 1] = item + elseif not ok then + self:_Log("Failed to snapshot unit ammunition " .. safe_tostring(unit_name) .. ": " .. safe_tostring(item)) + end + end + return result +end + +function MOOSE_BRIDGE:_BuildAirbaseNameSet() + local names = {} + if not _DATABASE or type(_DATABASE.AIRBASES) ~= "table" then return names end + for airbase_name, airbase in pairs(_DATABASE.AIRBASES) do + local name = self:_SafeCall(airbase, "GetName") or airbase.AirbaseName or airbase_name + if name then names[safe_tostring(name)] = true end + end + return names +end + +function MOOSE_BRIDGE:_ZoneName(zone_name, zone) + local name = self:_SafeCall(zone, "GetName") + if not name and zone then name = zone.ZoneName end + return name or zone_name +end + +function MOOSE_BRIDGE:_ZonePolygonVertices(zone) + local vec2_vertices = self:_SafeCall(zone, "GetVerticiesVec2") + if type(vec2_vertices) ~= "table" or #vec2_vertices < 3 then return nil end + local vertices = {} + for _, vec2 in ipairs(vec2_vertices) do + local x = self:_NumberOrNil(vec2 and vec2.x) + local z = self:_NumberOrNil(vec2 and vec2.y) + if x ~= nil and z ~= nil then + local coordinates = self:_CoordinatesForPoint({x=x, y=0, z=z}, "ll") + vertices[#vertices + 1] = {x=x, z=z, latitude=coordinates.latitude, longitude=coordinates.longitude} + end + end + if #vertices < 3 then return nil end + return vertices +end + +function MOOSE_BRIDGE:_BuildZoneSnapshotItem(zone_name, zone, source) + local name = self:_ZoneName(zone_name, zone) + if not name then return nil end + local point = self:_PointFromMooseObject(zone) + if not point and env and env.mission and env.mission.triggers and type(env.mission.triggers.zones) == "table" then + for _, trigger_zone in pairs(env.mission.triggers.zones) do + if trigger_zone.name == name then point = {x=trigger_zone.x, y=0, z=trigger_zone.y}; break end + end + end + local vertices = self:_ZonePolygonVertices(zone) + local radius = nil + if not vertices then radius = self:_SafeCall(zone, "GetRadius") or zone.radius end + local item = {object_id="ZONE:"..safe_tostring(name),dcs_name=safe_tostring(name),object_type="ZONE",category="ZONE",class_name=zone.ClassName,shape=vertices and "polygon" or "circle",source=source,radius=radius,vertices=vertices} + if point then self:_AddPointFields(item, point) end + return item +end + +function MOOSE_BRIDGE:BuildZoneSnapshot() + local result = {} + local seen = {} + local airbase_names = self:_BuildAirbaseNameSet() + for name, zone in pairs(self.RegisteredZones or {}) do + local ok, item = pcall(function() return self:_BuildZoneSnapshotItem(name, zone, "registered") end) + if ok and item and item.object_id then result[#result + 1] = item; seen[item.object_id] = true end + end + if _DATABASE and _DATABASE.ZONES then + for name, zone in pairs(_DATABASE.ZONES) do + local zone_name = self:_ZoneName(name, zone) + if zone_name and not airbase_names[safe_tostring(zone_name)] then + local ok, item = pcall(function() return self:_BuildZoneSnapshotItem(zone_name, zone, "database.ZONES") end) + if ok and item and item.object_id and not seen[item.object_id] then result[#result + 1] = item; seen[item.object_id] = true end + end + end + end + if env and env.mission and env.mission.triggers and type(env.mission.triggers.zones) == "table" then + for _, zone in pairs(env.mission.triggers.zones) do + local object_id = "ZONE:" .. safe_tostring(zone.name) + if not seen[object_id] then + local item = {object_id=object_id,dcs_name=safe_tostring(zone.name),object_type="ZONE",category="ZONE",shape="circle",source="mission.triggers.zones",x=zone.x,y=0,z=zone.y,radius=zone.radius} + result[#result + 1] = item + seen[object_id] = true + end + end + end + return result +end + +function MOOSE_BRIDGE:_BuildTerritorySnapshotItem(territory_name, territory, source) + local name = self:_SafeCall(territory, "GetName") or territory_name + if not name then return nil end + local zone = self:_SafeCall(territory, "GetZone") or territory.zone + if not zone then return nil end + local zone_name = self:_SafeCall(territory, "GetZoneName") or self:_ZoneName(nil, zone) + local zone_item = self:_BuildZoneSnapshotItem(zone_name, zone, source .. ".zone") + if not zone_item then return nil end + return { + object_id="TERRITORY:"..safe_tostring(name), + dcs_name=safe_tostring(name), + name=safe_tostring(name), + object_type="TERRITORY", + category="TERRITORY", + class_name=territory.ClassName or "TERRITORY", + source=source, + zone_name=safe_tostring(zone_name), + zone_class_name=zone.ClassName, + coalition=self:_CoalitionToName(self:_SafeCall(territory, "GetCoalition") or territory.coalition), + shape=zone_item.shape, + radius=zone_item.radius, + vertices=zone_item.vertices, + x=zone_item.x, + y=zone_item.y, + z=zone_item.z, + latitude=zone_item.latitude, + longitude=zone_item.longitude, + } +end + +function MOOSE_BRIDGE:BuildTerritorySnapshot() + local result = {} + if not _DATABASE or type(_DATABASE.TERRITORIES) ~= "table" then return result end + for name, territory in pairs(_DATABASE.TERRITORIES) do + local ok, item = pcall(function() return self:_BuildTerritorySnapshotItem(name, territory, "database.TERRITORIES") end) + if ok and item and item.object_id then + result[#result + 1] = item + else + self:_Log("Failed to snapshot territory " .. safe_tostring(name) .. ": " .. safe_tostring(item)) + end + end + return result +end + +function MOOSE_BRIDGE:BuildObjectSnapshot() + local objects = {} + local function append_all(items) for _, item in ipairs(items or {}) do objects[#objects + 1] = item end end + append_all(self:BuildGroupSnapshot()) + append_all(self:BuildUnitSnapshot()) + append_all(self:BuildStaticSnapshot()) + append_all(self:BuildAirbaseSnapshot()) + append_all(self:BuildZoneSnapshot()) + append_all(self:BuildTerritorySnapshot()) + return objects +end + +function MOOSE_BRIDGE:_OpsName(object, fallback) + return self:_ObjectName(object) or fallback +end + +function MOOSE_BRIDGE:_OpsState(object) + return self:_SafeCall(object, "GetState") or self:_SafeCall(object, "GetStatus") +end + +function MOOSE_BRIDGE:_OpsClassName(object, fallback) + if not object then return fallback end + return string_or_nil(object.ClassName or fallback) +end + +function MOOSE_BRIDGE:_OpsGroupKind(opsgroup) + if self:_SafeCall(opsgroup, "IsFlightgroup") then return "FLIGHTGROUP" end + if self:_SafeCall(opsgroup, "IsArmygroup") then return "ARMYGROUP" end + if self:_SafeCall(opsgroup, "IsNavygroup") then return "NAVYGROUP" end + return self:_OpsClassName(opsgroup, "OPSGROUP") +end + +function MOOSE_BRIDGE:_OpsCoalition(opsgroup) + local value = self:_SafeCall(opsgroup, "GetCoalition") + if value == nil and opsgroup then value = opsgroup.coalition end + return self:_CoalitionToName(value) +end + +function MOOSE_BRIDGE:_CollectDetectedGroupIds(opsgroup) + local result = {}; local seen = {} + local detected = self:_SafeCall(opsgroup, "GetDetectedGroupSet") or self:_SafeCall(opsgroup, "GetDetectedSet") + if detected and detected.Set then + for name, _ in pairs(detected.Set) do append_unique(result, seen, "GROUP:" .. safe_tostring(name)) end + end + return result +end + +function MOOSE_BRIDGE:_CollectAuftragIdsFromQueue(queue) + local result = {}; local seen = {} + if type(queue) ~= "table" then return result end + for _, auftrag in pairs(queue) do + local id = self:_AuftragObjectId(auftrag) + append_unique(result, seen, id) + end + return result +end + +function MOOSE_BRIDGE:_AuftragNumber(auftrag) + if not auftrag then return nil end + return auftrag.auftragsnummer or auftrag.uid or auftrag.id +end + +function MOOSE_BRIDGE:_AuftragObjectId(auftrag) + local number = self:_AuftragNumber(auftrag) + if number == nil then return nil end + return "AUFTRAG:" .. safe_tostring(number) +end + +function MOOSE_BRIDGE:_AuftragObjectIdFromValue(value) + if value == nil then return nil end + if type(value) == "table" then return self:_AuftragObjectId(value) end + local text = safe_tostring(value) + if string.find(text, "^AUFTRAG:") then return text end + return "AUFTRAG:" .. text +end + +function MOOSE_BRIDGE:_BuildOpsGroupSnapshotItem(group_name, opsgroup, source) + local name = self:_OpsName(opsgroup, group_name) + if not name then return nil end + local group_kind = self:_OpsGroupKind(opsgroup) + local point = self:_PointFromMooseObject(opsgroup) + local state = self:_OpsState(opsgroup) + local alive = self:_SafeCall(opsgroup, "IsAlive") + local active = self:_SafeCall(opsgroup, "IsActive") + local current = opsgroup and (opsgroup.currentmission or opsgroup.missioncurrent or opsgroup.currentMission) or nil + local current_id = self:_AuftragObjectIdFromValue(current) + local item = { + object_id="OPSGROUP:"..safe_tostring(name), + dcs_name=safe_tostring(name), + object_type="OPSGROUP", + category=group_kind, + class_name=self:_OpsClassName(opsgroup, "OPSGROUP"), + source=source, + name=safe_tostring(name), + group_name=safe_tostring(name), + state=string_or_nil(state), + coalition=self:_OpsCoalition(opsgroup), + alive=self:_BoolOrFalse(alive), + active=self:_BoolOrFalse(active), + is_ai=self:_BoolOrFalse(opsgroup and opsgroup.isAI), + is_late_activated=self:_BoolOrFalse(opsgroup and opsgroup.isLateActivated), + is_uncontrolled=self:_BoolOrFalse(opsgroup and opsgroup.isUncontrolled), + is_dead=self:_BoolOrFalse(opsgroup and opsgroup.isDead), + is_destroyed=self:_BoolOrFalse(opsgroup and opsgroup.isDestroyed), + current_wp=opsgroup and opsgroup.currentwp or nil, + speed_cruise=opsgroup and opsgroup.speedCruise or nil, + speed_wp=opsgroup and opsgroup.speedWp or nil, + heading=opsgroup and opsgroup.heading or nil, + travel_dist=opsgroup and opsgroup.traveldist or nil, + travel_time=opsgroup and opsgroup.traveltime or nil, + homebase_name=self:_ObjectName(opsgroup and opsgroup.homebase), + destbase_name=self:_ObjectName(opsgroup and opsgroup.destbase), + currbase_name=self:_ObjectName(opsgroup and opsgroup.currbase), + auftrag_current_id=current_id, + auftrag_queue_ids=self:_CollectAuftragIdsFromQueue(opsgroup and opsgroup.missionqueue), + detected_group_ids=self:_CollectDetectedGroupIds(opsgroup), + } + if point then self:_AddPointFields(item, point) end + return item +end + +function MOOSE_BRIDGE:BuildOpsGroupSnapshot() + local result = {}; local seen = {} + for name, opsgroup in pairs(self.RegisteredOpsGroups or {}) do + local ok, item = pcall(function() return self:_BuildOpsGroupSnapshotItem(name, opsgroup, "registered") end) + if ok and item and item.object_id then result[#result + 1] = item; seen[item.object_id] = true end + end + -- MOOSE stores all OPSGROUP specializations here despite the FLIGHTGROUPS name. + if _DATABASE and type(_DATABASE.FLIGHTGROUPS) == "table" then + for name, opsgroup in pairs(_DATABASE.FLIGHTGROUPS) do + local ok, item = pcall(function() return self:_BuildOpsGroupSnapshotItem(name, opsgroup, "database.FLIGHTGROUPS") end) + if ok and item and item.object_id and not seen[item.object_id] then result[#result + 1] = item; seen[item.object_id] = true end + end + end + return result +end + +function MOOSE_BRIDGE:_AddAuftragCandidate(result, seen, auftrag, source) + if type(auftrag) ~= "table" then return end + local object_id = self:_AuftragObjectId(auftrag) + if not object_id or seen[object_id] then return end + local ok, item = pcall(function() return self:_BuildAuftragSnapshotItem(auftrag, source) end) + if ok and item and item.object_id then + result[#result + 1] = item + seen[item.object_id] = true + end +end + +function MOOSE_BRIDGE:_CollectAuftragCandidatesFromOpsGroup(result, seen, opsgroup) + if type(opsgroup) ~= "table" then return end + if type(opsgroup.missionqueue) == "table" then + for _, auftrag in pairs(opsgroup.missionqueue) do self:_AddAuftragCandidate(result, seen, auftrag, "opsgroup.missionqueue") end + end +end + +function MOOSE_BRIDGE:_PointFromCoordinate(coordinate) + if not coordinate then return nil end + local vec3 = self:_SafeCall(coordinate, "GetVec3") + if vec3 then return vec3 end + if coordinate.x and coordinate.z then return {x=coordinate.x, y=coordinate.y or 0, z=coordinate.z} end + return nil +end + +function MOOSE_BRIDGE:_TargetObjectId(target_object) + if not target_object then return nil end + local target_type = target_object.Type + local name = target_object.Name + if not target_type or not name then return nil end + local prefix_by_type = { + Group="GROUP", + Unit="UNIT", + Static="STATIC", + Scenery="SCENERY", + Airbase="AIRBASE", + Zone="ZONE", + OpsZone="OPSZONE", + } + local prefix = prefix_by_type[target_type] + if not prefix then return nil end + return prefix .. ":" .. safe_tostring(name) +end + +function MOOSE_BRIDGE:_BuildTargetObjectSnapshot(target, target_object) + if type(target_object) ~= "table" then return nil end + local coordinate = self:_SafeCallArg(target, "GetTargetCoordinate", target_object) or target_object.Coordinate + local point = self:_PointFromCoordinate(coordinate) + local item = { + id=target_object.ID, + type=string_or_nil(target_object.Type), + name=string_or_nil(target_object.Name), + object_id=self:_TargetObjectId(target_object), + status=string_or_nil(target_object.Status), + n0=target_object.N0, + n_dead=target_object.Ndead, + n_destroyed=target_object.Ndestroyed, + life=target_object.Life, + life0=target_object.Life0, + } + if point then self:_AddPointFields(item, point) end + return item +end + +function MOOSE_BRIDGE:_BuildTargetSnapshot(target) + if type(target) ~= "table" then return nil end + local target_objects = {} + if type(target.targets) == "table" then + for _, target_object in pairs(target.targets) do + local ok, item = pcall(function() return self:_BuildTargetObjectSnapshot(target, target_object) end) + if ok and item then target_objects[#target_objects + 1] = item end + end + end + + local point = self:_SafeCall(target, "GetVec3") + if not point then point = self:_PointFromCoordinate(self:_SafeCall(target, "GetCoordinate")) end + + local item = { + object_id=target.uid and ("TARGET:" .. safe_tostring(target.uid)) or nil, + name=string_or_nil(self:_SafeCall(target, "GetName") or target.name), + state=string_or_nil(self:_SafeCall(target, "GetState")), + category=string_or_nil(self:_SafeCall(target, "GetCategory") or target.category), + heading=self:_SafeCall(target, "GetHeading"), + life=self:_SafeCall(target, "GetLife") or target.life, + life0=self:_SafeCall(target, "GetLife0") or target.life0, + damage=self:_SafeCall(target, "GetDamage"), + threat_level_max=self:_SafeCall(target, "GetThreatLevelMax") or target.threatlevel0, + n0=target.N0, + n_targets0=target.Ntargets0, + n_destroyed=target.Ndestroyed, + n_dead=target.Ndead, + is_destroyed=self:_BoolOrFalse(target.isDestroyed), + objects=target_objects, + } + if point then self:_AddPointFields(item, point) end + return item +end + +function MOOSE_BRIDGE:_CollectLegionNames(legions) + local result = {}; local seen = {} + if type(legions) ~= "table" then return result end + for key, legion in pairs(legions) do + local name = self:_ObjectName(legion) + if not name and type(key) == "string" then name = key end + append_unique(result, seen, name) + end + return result +end + +function MOOSE_BRIDGE:_LegionKind(legion) + if self:_SafeCall(legion, "IsAirwing") then return "AIRWING" end + if self:_SafeCall(legion, "IsBrigade") then return "BRIGADE" end + if self:_SafeCall(legion, "IsFleet") then return "FLEET" end + return self:_OpsClassName(legion, "LEGION") +end + +function MOOSE_BRIDGE:_LegionName(legion, fallback) + if not legion then return fallback end + return self:_SafeCall(legion, "GetName") or legion.alias or fallback +end + +function MOOSE_BRIDGE:_CohortName(cohort, fallback) + if not cohort then return fallback end + return self:_SafeCall(cohort, "GetName") or cohort.name or fallback +end + +function MOOSE_BRIDGE:_CohortKind(cohort) + if not cohort then return nil end + if cohort.isAir then return "AIR" end + if cohort.isGround then return "GROUND" end + if cohort.isNaval then return "NAVAL" end + return self:_OpsClassName(cohort, "COHORT") +end + +function MOOSE_BRIDGE:_CollectCohortIds(cohorts) + local result = {}; local seen = {} + if type(cohorts) ~= "table" then return result end + for index, cohort in pairs(cohorts) do + local fallback = type(index) == "string" and index or nil + local name = self:_CohortName(cohort, fallback) + append_unique(result, seen, name and ("COHORT:" .. safe_tostring(name)) or nil) + end + return result +end + +function MOOSE_BRIDGE:_BuildCohortSummary(cohort, index) + local fallback = type(index) == "string" and index or nil + local name = self:_CohortName(cohort, fallback) + if not name then return nil end + return { + object_id="COHORT:" .. safe_tostring(name), + name=safe_tostring(name), + category=self:_CohortKind(cohort), + class_name=self:_OpsClassName(cohort, "COHORT"), + is_air=self:_BoolOrFalse(cohort and cohort.isAir), + is_ground=self:_BoolOrFalse(cohort and cohort.isGround), + is_naval=self:_BoolOrFalse(cohort and cohort.isNaval), + } +end + +function MOOSE_BRIDGE:_BuildCohortSummaries(cohorts) + local result = {} + if type(cohorts) ~= "table" then return result end + for index, cohort in pairs(cohorts) do + local ok, item = pcall(function() return self:_BuildCohortSummary(cohort, index) end) + if ok and item then result[#result + 1] = item end + end + return result +end + +function MOOSE_BRIDGE:_CommanderName(commander, fallback) + if not commander then return fallback end + return commander.alias or self:_SafeCall(commander, "GetName") or fallback +end + +function MOOSE_BRIDGE:_CollectLegionIds(legions) + local result = {}; local seen = {} + if type(legions) ~= "table" then return result end + for index, legion in pairs(legions) do + local fallback = type(index) == "string" and index or nil + local name = self:_LegionName(legion, fallback) + append_unique(result, seen, name and ("LEGION:" .. safe_tostring(name)) or nil) + end + return result +end + +function MOOSE_BRIDGE:_BuildCommanderSnapshotItem(commander_name, commander, source) + local name = self:_CommanderName(commander, commander_name) + if not name then return nil end + return { + object_id="COMMANDER:" .. safe_tostring(name), + dcs_name=safe_tostring(name), + object_type="COMMANDER", + category="COMMANDER", + class_name=self:_OpsClassName(commander, "COMMANDER"), + source=source, + name=safe_tostring(name), + alias=string_or_nil(commander and commander.alias), + state=string_or_nil(self:_SafeCall(commander, "GetState")), + coalition=self:_CoalitionToName(self:_SafeCall(commander, "GetCoalition") or (commander and commander.coalition)), + legion_ids=self:_CollectLegionIds(commander and commander.legions), + n_legions=self:_CountTable((commander and commander.legions) or {}), + available_asset_count=self:_NumberOrNil(self:_SafeCall(commander, "CountAvailableAssets")), + auftrag_queue_ids=self:_CollectAuftragIdsFromQueue(commander and commander.missionqueue), + } +end + +function MOOSE_BRIDGE:BuildCommanderSnapshot() + local result = {}; local seen = {} + local function add(name, commander, source) + local ok, item = pcall(function() return self:_BuildCommanderSnapshotItem(name, commander, source) end) + if ok and item and item.object_id and not seen[item.object_id] then + result[#result + 1] = item + seen[item.object_id] = true + elseif not ok then + self:_Log("Failed to snapshot commander " .. safe_tostring(name) .. ": " .. safe_tostring(item)) + end + end + for name, commander in pairs(self.RegisteredCommanders or {}) do add(name, commander, "registered") end + if _DATABASE and type(_DATABASE.COMMANDERS) == "table" then + for name, commander in pairs(_DATABASE.COMMANDERS) do add(name, commander, "database.COMMANDERS") end + end + return result +end + +function MOOSE_BRIDGE:_BuildLegionSnapshotItem(legion_name, legion, source) + local name = self:_LegionName(legion, legion_name) + if not name then return nil end + local point = self:_PointFromMooseObject(legion) + local airbase = self:_SafeCall(legion, "GetAirbase") + local item = { + object_id="LEGION:"..safe_tostring(name), + dcs_name=safe_tostring(name), + object_type="LEGION", + category=self:_LegionKind(legion), + class_name=self:_OpsClassName(legion, "LEGION"), + source=source, + name=safe_tostring(name), + alias=string_or_nil(legion and legion.alias), + state=string_or_nil(self:_SafeCall(legion, "GetState")), + coalition=self:_CoalitionToName(self:_SafeCall(legion, "GetCoalition")), + coalition_name=string_or_nil(self:_SafeCall(legion, "GetCoalitionName")), + airbase_name=string_or_nil(self:_SafeCall(legion, "GetAirbaseName") or self:_ObjectName(airbase)), + cohort_ids=self:_CollectCohortIds(legion and legion.cohorts), + cohorts=self:_BuildCohortSummaries(legion and legion.cohorts), + n_cohorts=self:_CountTable((legion and legion.cohorts) or {}), + available_asset_count=self:_NumberOrNil(self:_SafeCall(legion, "CountAvailableAssets")), + auftrag_queue_ids=self:_CollectAuftragIdsFromQueue(legion and legion.missionqueue), + } + if point then self:_AddPointFields(item, point) end + return item +end + +function MOOSE_BRIDGE:BuildLegionSnapshot() + local result = {}; local seen = {} + if _DATABASE and type(_DATABASE.LEGIONS) == "table" then + for name, legion in pairs(_DATABASE.LEGIONS) do + local ok, item = pcall(function() return self:_BuildLegionSnapshotItem(name, legion, "database.LEGIONS") end) + if ok and item and item.object_id and not seen[item.object_id] then + result[#result + 1] = item + seen[item.object_id] = true + elseif not ok then + self:_Log("Failed to snapshot legion " .. safe_tostring(name) .. ": " .. safe_tostring(item)) + end + end + end + return result +end + +function MOOSE_BRIDGE:_CohortObjectId(cohort, fallback) + local name = self:_CohortName(cohort, fallback) + if not name then return nil end + return "COHORT:" .. safe_tostring(name) +end + +function MOOSE_BRIDGE:_CollectMissionTypeNames(mission_types) + local result = {}; local seen = {} + if type(mission_types) ~= "table" then return result end + for key, value in pairs(mission_types) do + if type(value) == "string" then + append_unique(result, seen, value) + elseif type(key) == "string" and value then + append_unique(result, seen, key) + elseif value ~= nil then + append_unique(result, seen, safe_tostring(value)) + end + end + return result +end + +function MOOSE_BRIDGE:_CollectMissionPerformance(cohort, mission_types) + local result = {} + if not cohort or type(mission_types) ~= "table" then return result end + for _, mission_type in pairs(mission_types) do + local performance = self:_SafeCallArg(cohort, "GetMissionPeformance", mission_type) + if performance == nil then performance = self:_SafeCallArg(cohort, "GetMissionPerformance", mission_type) end + if type(performance) == "number" then result[safe_tostring(mission_type)] = performance end + end + return result +end + +function MOOSE_BRIDGE:_CollectOpsGroupIdsFromSet(set_opsgroup) + local result = {}; local seen = {} + if not set_opsgroup then return result end + + local for_each = set_opsgroup.ForEachOpsGroup or set_opsgroup.ForEach + if for_each then + pcall(function() + for_each(set_opsgroup, function(opsgroup) + local name = self:_OpsName(opsgroup, nil) + if name then append_unique(result, seen, "OPSGROUP:" .. safe_tostring(name)) end + end) + end) + end + + if #result == 0 and type(set_opsgroup.Set) == "table" then + for name, opsgroup in pairs(set_opsgroup.Set) do + local opsgroup_name = self:_OpsName(opsgroup, type(name) == "string" and name or nil) + if opsgroup_name then append_unique(result, seen, "OPSGROUP:" .. safe_tostring(opsgroup_name)) end + end + end + + return result +end + +function MOOSE_BRIDGE:_CollectCohortIndirectMissionRanges(cohort) + local result = {} + local weapon_types = { + 16384, -- HeavyRocket + 30720, -- AnyRocket + 68719476736, -- SubmunitionDispenserShell + 137438953472, -- GuidedShell + 206963736576, -- ConventionalShell + 258503344128, -- AnyShell + } + for _, weapon_type in ipairs(weapon_types) do + local mission_range = self:_SafeCallArg(cohort, "GetMissionRange", {weapon_type}) + if type(mission_range) == "number" then + result[string.format("%.0f", weapon_type)] = mission_range + end + end + return result +end + +function MOOSE_BRIDGE:_CollectCohortWeaponRanges(cohort) + local result = {} + if type(cohort) ~= "table" or type(cohort.weaponData) ~= "table" then return result end + for key, weapon in pairs(cohort.weaponData) do + if type(weapon) == "table" then + local bit_type = self:_NumberOrNil(weapon.BitType) or self:_NumberOrNil(key) + if bit_type ~= nil then + result[string.format("%.0f", bit_type)] = { + weapon_type=bit_type, + minimum_m=self:_NumberOrNil(weapon.RangeMin), + maximum_m=self:_NumberOrNil(weapon.RangeMax), + } + end + end + end + return result +end + +function MOOSE_BRIDGE:_AnalyzeCohortComposition(cohort) + if type(cohort) ~= "table" or type(cohort.assets) ~= "table" then return false, nil end + local expected_type = nil + local expected_count = nil + local uniform_count = true + local inspected = false + for _, asset in pairs(cohort.assets) do + local units = asset and asset.template and asset.template.units + if type(units) == "table" and #units > 0 then + local count = #units + if expected_count ~= nil and count ~= expected_count then uniform_count = false end + if expected_count == nil then expected_count = count end + for _, unit in ipairs(units) do + local unit_type = unit and (unit.type or unit.typeName) + if type(unit_type) ~= "string" or unit_type == "" then return false, nil end + if expected_type ~= nil and unit_type ~= expected_type then return false, nil end + expected_type = unit_type + inspected = true + end + end + end + if not inspected then return false, nil end + return true, uniform_count and expected_count or nil +end + +function MOOSE_BRIDGE:_BuildCohortSnapshotItem(cohort_name, cohort, source) + local name = self:_CohortName(cohort, cohort_name) + if not name then return nil end + local legion_name = self:_LegionName(cohort and cohort.legion, nil) + local mission_types = self:_CollectMissionTypeNames(self:_SafeCall(cohort, "GetMissionTypes")) + local opsgroups = self:_SafeCall(cohort, "GetOpsGroups") + local point = self:_PointFromMooseObject(cohort) + local homogeneous, units_per_asset = self:_AnalyzeCohortComposition(cohort) + + local item = { + object_id="COHORT:"..safe_tostring(name), + dcs_name=safe_tostring(name), + object_type="COHORT", + category=self:_CohortKind(cohort), + class_name=self:_OpsClassName(cohort, "COHORT"), + source=source, + name=safe_tostring(name), + legion_id=legion_name and ("LEGION:" .. safe_tostring(legion_name)) or nil, + legion_name=string_or_nil(legion_name), + is_air=self:_BoolOrFalse(cohort and cohort.isAir), + is_ground=self:_BoolOrFalse(cohort and cohort.isGround), + is_naval=self:_BoolOrFalse(cohort and cohort.isNaval), + mission_types=mission_types, + mission_performance=self:_CollectMissionPerformance(cohort, mission_types), + skill=cohort and cohort.skill or nil, + homogeneous=homogeneous, + configured_grouping=self:_NumberOrNil(cohort and cohort.ngrouping), + units_per_asset=units_per_asset, + engage_range_m=self:_NumberOrNil(cohort and cohort.engageRange), + mission_range_m=self:_NumberOrNil(self:_SafeCall(cohort, "GetMissionRange")), + mission_ranges_by_weapon_type=self:_CollectCohortIndirectMissionRanges(cohort), + weapon_ranges_by_type=self:_CollectCohortWeaponRanges(cohort), + asset_count=self:_NumberOrNil(self:_SafeCall(cohort, "CountAssets")), + stock_asset_count=self:_NumberOrNil(self:_SafeCallArg(cohort, "CountAssets", true)), + available_asset_count=self:_NumberOrNil(self:_SafeCall(cohort, "CountAvailableAssets")), + spawned_asset_count=self:_NumberOrNil(self:_SafeCallArg(cohort, "CountAssets", false)), + opsgroup_count=self:_CountSet(opsgroups), + opsgroup_ids=self:_CollectOpsGroupIdsFromSet(opsgroups), + } + if point then self:_AddPointFields(item, point) end + return item +end + +function MOOSE_BRIDGE:BuildCohortSnapshot() + local result = {}; local seen = {} + if _DATABASE and type(_DATABASE.COHORTS) == "table" then + for name, cohort in pairs(_DATABASE.COHORTS) do + local ok, item = pcall(function() return self:_BuildCohortSnapshotItem(name, cohort, "database.COHORTS") end) + if ok and item and item.object_id and not seen[item.object_id] then + result[#result + 1] = item + seen[item.object_id] = true + elseif not ok then + self:_Log("Failed to snapshot cohort " .. safe_tostring(name) .. ": " .. safe_tostring(item)) + end + end + end + return result +end + +function MOOSE_BRIDGE:_BuildAuftragSnapshotItem(auftrag, source) + local object_id = self:_AuftragObjectId(auftrag) + local auftrag_type = self:_SafeCall(auftrag, "GetType") or auftrag.type + local assigned_group_ids = {} + local group_seen = {} + local opsgroups = self:_SafeCall(auftrag, "GetOpsGroups") + if type(opsgroups) == "table" then + for _, opsgroup in pairs(opsgroups) do + local name = self:_OpsName(opsgroup, nil) + if name then append_unique(assigned_group_ids, group_seen, "OPSGROUP:" .. safe_tostring(name)) end + end + end + return { + object_id=object_id, + dcs_name=safe_tostring(auftrag.name or object_id), + object_type="AUFTRAG", + category=string_or_nil(auftrag_type), + source=source, + auftragsnummer=self:_AuftragNumber(auftrag), + name=string_or_nil(auftrag.name), + type=string_or_nil(auftrag_type), + status=string_or_nil(self:_SafeCall(auftrag, "GetState") or auftrag.status), + prio=auftrag.prio, + urgent=self:_BoolOrFalse(auftrag.urgent), + importance=auftrag.importance, + t_start=auftrag.Tstart, + t_stop=auftrag.Tstop, + duration=auftrag.duration, + duration_exe=auftrag.durationExe, + t_started=auftrag.Tstarted, + t_executing=auftrag.Texecuting, + t_push=auftrag.Tpush, + t_over=auftrag.Tover, + n_assigned=auftrag.Nassigned, + n_elements=auftrag.Nelements, + n_dead=auftrag.Ndead, + n_kills=auftrag.Nkills, + n_casualties=auftrag.Ncasualties, + mission_task=string_or_nil(auftrag.missionTask), + mission_altitude=auftrag.missionAltitude, + mission_speed=auftrag.missionSpeed, + mission_range=auftrag.missionRange, + chief_name=self:_ObjectName(auftrag.chief), + commander_name=self:_ObjectName(auftrag.commander), + operation_name=self:_ObjectName(auftrag.operation), + assigned_group_ids=assigned_group_ids, + legion_names=self:_CollectLegionNames(auftrag.legions), + target=self:_BuildTargetSnapshot(auftrag.engageTarget), + } +end + +function MOOSE_BRIDGE:BuildAuftragSnapshot() + local result = {}; local seen = {} + for _, opsgroup in pairs(self.RegisteredOpsGroups or {}) do self:_CollectAuftragCandidatesFromOpsGroup(result, seen, opsgroup) end + -- MOOSE stores all OPSGROUP specializations here despite the FLIGHTGROUPS name. + if _DATABASE and type(_DATABASE.FLIGHTGROUPS) == "table" then + for _, opsgroup in pairs(_DATABASE.FLIGHTGROUPS) do self:_CollectAuftragCandidatesFromOpsGroup(result, seen, opsgroup) end + end + return result +end + +function MOOSE_BRIDGE:RegisterDefaultCommands() + self:RegisterCommand("time.get", function(cmd) + return {action="time.get", mission_time=mission_time(), dcs_time=dcs_time(), mission_date=self.MissionDate, wall_time=wall_time()} + end) + + self:RegisterCommand("message.to_all", function(cmd) + local p = cmd.params or {} + MESSAGE:New(p.text or "", p.duration or 10):ToAll() + return {text=p.text, duration=p.duration or 10} + end) + + self:RegisterCommand("message.to_coalition", function(cmd) + local p = cmd.params or {} + local side = coalition_from_name(p.coalition or "blue") + if side == nil then error("Unknown coalition " .. safe_tostring(p.coalition)) end + MESSAGE:New(p.text or "", p.duration or 10):ToCoalition(side) + return {coalition=p.coalition, text=p.text, duration=p.duration or 10} + end) + + local smoke_at_point_handler = function(cmd) + local p = cmd.params or {} + local point = self:_PointFromParams(p) + return self:_SmokePoint(point, p.color or "white") + end + self:RegisterCommand("smoke.at_point", smoke_at_point_handler) + self:RegisterCommand("smoke.point", smoke_at_point_handler) + + local mark_at_point_handler = function(cmd) + local p = cmd.params or {} + local point = self:_PointFromParams(p) + return self:_MarkPoint(point, p.text or "MOOSE Bridge mark") + end + self:RegisterCommand("mark.at_point", mark_at_point_handler) + self:RegisterCommand("mark.point", mark_at_point_handler) + + self:RegisterCommand("smoke.object", function(cmd) + local p = cmd.params or {}; local point = self:_PointForObjectId(p.object_id) + return self:_SmokePoint(point, p.color or "white") + end) + + local explosion_at_point_handler = function(cmd) + local p = cmd.params or {} + local point = self:_PointFromParams(p) + if p.y == nil and land and land.getHeight then + point.y = land.getHeight({x=point.x, y=point.z}) + end + return self:_ExplosionPoint(point, p.power, p.delay) + end + self:RegisterCommand("explosion.at_point", explosion_at_point_handler) + self:RegisterCommand("explosion.point", explosion_at_point_handler) + + self:RegisterCommand("explosion.object", function(cmd) + local p = cmd.params or {}; local point = self:_PointForObjectId(p.object_id) + return self:_ExplosionPoint(point, p.power, p.delay) + end) + + self:RegisterCommand("mark.object", function(cmd) + local p = cmd.params or {}; local point = self:_PointForObjectId(p.object_id) + return self:_MarkPoint(point, p.text or "MOOSE Bridge mark") + end) + + self:RegisterCommand("map.overlay.draw", function(cmd) + return self:_DrawDebugOverlay(cmd.params or {}) + end) + + self:RegisterCommand("map.overlay.clear", function(cmd) + local p = cmd.params or {} + local overlay_id = self:_OptionalString(p.overlay_id) + local removed = overlay_id and self:_ClearDebugOverlay(overlay_id) or self:_ClearDebugOverlays() + return {action="map.overlay.clear", overlay_id=overlay_id, removed=removed} + end) + + self:RegisterCommand("object.coords", function(cmd) + local p = cmd.params or {} + local object_id = self:_OptionalString(p.object_id) + local point = self:_PointForObjectId(object_id) + local result = self:_CoordinatesForPoint(point, p.format) + result.action = "object.coords" + result.object_id = object_id + return result + end) + + self:RegisterCommand("coordinates.convert_points", function(cmd) + local p = cmd.params or {} + if type(p.points) ~= "table" then error("coordinates.convert_points requires points") end + if #p.points > 5000 then error("coordinates.convert_points accepts at most 5000 points") end + local points = {} + for index, point in ipairs(p.points) do + if type(point) ~= "table" or type(point.x) ~= "number" or type(point.z) ~= "number" then + error("Invalid point at index " .. safe_tostring(index)) + end + local converted = self:_CoordinatesForPoint({x=point.x, y=point.y or 0, z=point.z}, "ll") + points[#points + 1] = { + x=converted.x, + y=converted.y, + z=converted.z, + latitude=converted.latitude, + longitude=converted.longitude, + } + end + return {action="coordinates.convert_points", count=#points, points=points} + end) + + self:RegisterCommand("terrain.closest_road_points", function(cmd) + local p = cmd.params or {} + if not land or not land.getClosestPointOnRoads then error("DCS land.getClosestPointOnRoads is not available") end + if type(p.points) ~= "table" or #p.points == 0 then error("terrain.closest_road_points requires points") end + if #p.points > 500 then error("terrain.closest_road_points accepts at most 500 points") end + local road_type = string.lower(tostring(p.road_type or "roads")) + if road_type ~= "roads" and road_type ~= "railroads" then error("road_type must be roads or railroads") end + local samples = {} + for index, value in ipairs(p.points) do + local point = self:_DebugMarkupPoint(value) + local road_x, road_z = land.getClosestPointOnRoads(road_type, point.x, point.z) + if type(road_x) ~= "number" or type(road_z) ~= "number" then + error("DCS returned no closest road point at index " .. safe_tostring(index)) + end + local road_y = land.getHeight and land.getHeight({x=road_x, y=road_z}) or 0 + local nearest = {x=road_x, y=road_y or 0, z=road_z} + local input_coordinates = self:_CoordinatesForPoint(point, "ll") + local nearest_coordinates = self:_CoordinatesForPoint(nearest, "ll") + samples[#samples + 1] = { + input_x=point.x, + input_y=point.y or 0, + input_z=point.z, + input_latitude=input_coordinates.latitude, + input_longitude=input_coordinates.longitude, + road_x=nearest.x, + road_y=nearest.y, + road_z=nearest.z, + road_latitude=nearest_coordinates.latitude, + road_longitude=nearest_coordinates.longitude, + distance_m=math.sqrt((road_x - point.x) ^ 2 + (road_z - point.z) ^ 2), + } + end + return {action="terrain.closest_road_points", road_type=road_type, count=#samples, samples=samples} + end) + + self:RegisterCommand("terrain.surface_types", function(cmd) + local p = cmd.params or {} + if not land or not land.getSurfaceType then error("DCS land.getSurfaceType is not available") end + if type(p.points) ~= "table" or #p.points == 0 then error("terrain.surface_types requires points") end + if #p.points > 500 then error("terrain.surface_types accepts at most 500 points") end + local surface_names = { + [1]="LAND", + [2]="SHALLOW_WATER", + [3]="WATER", + [4]="ROAD", + [5]="RUNWAY", + } + local shallow_water = land.SurfaceType and land.SurfaceType.SHALLOW_WATER or 2 + local water = land.SurfaceType and land.SurfaceType.WATER or 3 + local samples = {} + for index, value in ipairs(p.points) do + local point = self:_DebugMarkupPoint(value) + local surface_type = land.getSurfaceType({x=point.x, y=point.z}) + if type(surface_type) ~= "number" then + error("DCS returned no surface type at index " .. safe_tostring(index)) + end + local coordinates = self:_CoordinatesForPoint(point, "ll") + samples[#samples + 1] = { + input_x=point.x, + input_y=point.y or 0, + input_z=point.z, + input_latitude=coordinates.latitude, + input_longitude=coordinates.longitude, + surface_type=surface_type, + surface_name=surface_names[surface_type] or "UNKNOWN", + is_water=surface_type == shallow_water or surface_type == water, + } + end + return {action="terrain.surface_types", count=#samples, samples=samples} + end) + + self:RegisterCommand("object.distance", function(cmd) + local p = cmd.params or {} + local object_id_a = self:_OptionalString(p.object_id_a) + local object_id_b = self:_OptionalString(p.object_id_b) + local point_a = self:_PointForObjectId(object_id_a) + local point_b = self:_PointForObjectId(object_id_b) + local meters = self:_DistanceBetweenPoints(point_a, point_b) + return { + action="object.distance", + object_id_a=object_id_a, + object_id_b=object_id_b, + distance_m=meters, + distance_km=meters / 1000, + distance_nm=meters / 1852, + } + end) + + self:RegisterCommand("zone.draw", function(cmd) + local p = cmd.params or {} + local object_id = self:_OptionalString(p.zone_id) or self:_OptionalString(p.object_id) + local zone, zone_name, zone_type = self:_ZoneForDrawObjectId(object_id) + local draw_coalition = self:_DrawZoneCoalition(p.coalition) + local color = self:_DrawZoneColor(p.color) + local alpha = self:_NumberOrNil(p.alpha) + local fill_color = self:_DrawZoneColor(p.fill_color) + local fill_alpha = self:_NumberOrNil(p.fill_alpha) + local line_type = self:_DrawZoneLineType(p.line_type) + zone:DrawZone(draw_coalition, color, alpha, fill_color, fill_alpha, line_type) + return { + action="zone.draw", + object_id=object_id, + zone_name=zone_name, + zone_type=zone_type, + coalition=draw_coalition, + color=p.color, + alpha=alpha, + fill_color=p.fill_color, + fill_alpha=fill_alpha, + line_type=line_type, + } + end) + + self:RegisterCommand("territory.set_coalition", function(cmd) + local p = cmd.params or {} + local object_id = self:_OptionalString(p.territory_id) or self:_OptionalString(p.object_id) + local object_type, name = self:_SplitObjectId(object_id) + if object_type ~= "TERRITORY" or not name or name == "" then + error("territory.set_coalition requires TERRITORY:") + end + local territory = self:_TerritoryForName(name) + if not territory then error("Territory not found: " .. safe_tostring(object_id)) end + local side = coalition_from_name(p.coalition) + if side == nil then error("Unknown coalition " .. safe_tostring(p.coalition)) end + local previous = self:_CoalitionToName(self:_SafeCall(territory, "GetCoalition") or territory.coalition) + local updated = self:_SafeCallArg(territory, "SetCoalition", side) + if not updated then error("Territory rejected coalition " .. safe_tostring(p.coalition)) end + local item = self:_BuildTerritorySnapshotItem(name, territory, "database.TERRITORIES") + self:SendEvent("territory.coalition_changed", { + territory_id=object_id, + previous_coalition=previous, + coalition=self:_CoalitionToName(side), + territory=item, + }) + return { + action="territory.set_coalition", + territory_id=object_id, + previous_coalition=previous, + coalition=self:_CoalitionToName(side), + } + end) + + self:RegisterCommand("snapshot.groups", function(cmd) + local groups = self:BuildGroupSnapshot(); self:SendSnapshot("groups", {groups=groups}); return {kind="groups", count=#groups} + end) + + self:RegisterCommand("snapshot.units", function(cmd) + local units = self:BuildUnitSnapshot(); self:SendSnapshot("units", {units=units}); return {kind="units", count=#units} + end) + + self:RegisterCommand("snapshot.ammunition", function(cmd) + local ammunition = self:BuildAmmunitionSnapshot(); self:SendSnapshot("ammunition", {ammunition=ammunition}); return {kind="ammunition", count=#ammunition} + end) + + self:RegisterCommand("snapshot.statics", function(cmd) + local statics = self:BuildStaticSnapshot(); self:SendSnapshot("statics", {statics=statics}); return {kind="statics", count=#statics} + end) + + self:RegisterCommand("snapshot.airbases", function(cmd) + local airbases = self:BuildAirbaseSnapshot(); self:SendSnapshot("airbases", {airbases=airbases}); return {kind="airbases", count=#airbases} + end) + + self:RegisterCommand("snapshot.zones", function(cmd) + local zones = self:BuildZoneSnapshot(); self:SendSnapshot("zones", {zones=zones}); return {kind="zones", count=#zones} + end) + + self:RegisterCommand("snapshot.territories", function(cmd) + local territories = self:BuildTerritorySnapshot(); self:SendSnapshot("territories", {territories=territories}); return {kind="territories", count=#territories} + end) + + self:RegisterCommand("snapshot.objects", function(cmd) + local objects = self:BuildObjectSnapshot(); self:SendSnapshot("objects", {objects=objects}); return {kind="objects", count=#objects} + end) + + self:RegisterCommand("snapshot.opszones", function(cmd) + local opszones = self:BuildOpsZoneSnapshot(); self:SendSnapshot("opszones", {opszones=opszones}); return {kind="opszones", count=#opszones} + end) + + self:RegisterCommand("snapshot.opsgroups", function(cmd) + local opsgroups = self:BuildOpsGroupSnapshot(); self:SendSnapshot("opsgroups", {opsgroups=opsgroups}); return {kind="opsgroups", count=#opsgroups} + end) + + self:RegisterCommand("snapshot.auftraege", function(cmd) + local auftraege = self:BuildAuftragSnapshot(); self:SendSnapshot("auftraege", {auftraege=auftraege}); return {kind="auftraege", count=#auftraege} + end) + + self:RegisterCommand("snapshot.legions", function(cmd) + local legions = self:BuildLegionSnapshot(); self:SendSnapshot("legions", {legions=legions}); return {kind="legions", count=#legions} + end) + + self:RegisterCommand("snapshot.commanders", function(cmd) + local commanders = self:BuildCommanderSnapshot(); self:SendSnapshot("commanders", {commanders=commanders}); return {kind="commanders", count=#commanders} + end) + + self:RegisterCommand("snapshot.cohorts", function(cmd) + local cohorts = self:BuildCohortSnapshot(); self:SendSnapshot("cohorts", {cohorts=cohorts}); return {kind="cohorts", count=#cohorts} + end) + + self:RegisterCommand("snapshot.all", function(cmd) + local groups = self:BuildGroupSnapshot() + local units = self:BuildUnitSnapshot() + local statics = self:BuildStaticSnapshot() + local airbases = self:BuildAirbaseSnapshot() + local zones = self:BuildZoneSnapshot() + local territories = self:BuildTerritorySnapshot() + local opszones = self:BuildOpsZoneSnapshot() + local opsgroups = self:BuildOpsGroupSnapshot() + local auftraege = self:BuildAuftragSnapshot() + local legions = self:BuildLegionSnapshot() + local cohorts = self:BuildCohortSnapshot() + local commanders = self:BuildCommanderSnapshot() + self:SendSnapshot("groups", {groups=groups}) + self:SendSnapshot("units", {units=units}) + self:SendSnapshot("statics", {statics=statics}) + self:SendSnapshot("airbases", {airbases=airbases}) + self:SendSnapshot("zones", {zones=zones}) + self:SendSnapshot("territories", {territories=territories}) + self:SendSnapshot("opszones", {opszones=opszones}) + self:SendSnapshot("opsgroups", {opsgroups=opsgroups}) + self:SendSnapshot("auftraege", {auftraege=auftraege}) + self:SendSnapshot("legions", {legions=legions}) + self:SendSnapshot("cohorts", {cohorts=cohorts}) + self:SendSnapshot("commanders", {commanders=commanders}) + return {groups=#groups, units=#units, statics=#statics, airbases=#airbases, zones=#zones, territories=#territories, opszones=#opszones, opsgroups=#opsgroups, auftraege=#auftraege, legions=#legions, cohorts=#cohorts, commanders=#commanders} + end) +end + +function MOOSE_BRIDGE:_ReadLine() + if not self.Socket then return nil, "no_socket" end + local line, err, partial = self.Socket:receive("*l") + if line then return line, nil end + if err == "timeout" then return nil, nil end + if partial and #partial > 0 then return partial, nil end + return nil, err +end + +function MOOSE_BRIDGE:_HandleCommand(line) + local ok, command = pcall(function() return json.decode(line) end) + if not ok or type(command) ~= "table" then self:_Log("Invalid command: " .. safe_tostring(command)); return end + local handler = self.CommandHandlers[command.action] + if not handler then self:SendAck(command, false, nil, "Unknown action: " .. safe_tostring(command.action)); return end + local ok_handler, result = pcall(function() return handler(command) end) + if ok_handler then self:SendAck(command, true, result, nil) else self:SendAck(command, false, nil, safe_tostring(result)) end +end + +function MOOSE_BRIDGE:_FlushOutQueue() + if not self.Socket or #self.OutQueue == 0 then return end + while #self.OutQueue > 0 do + local line = table.remove(self.OutQueue, 1) + local ok, err = self.Socket:send(line .. "\n") + if not ok then self:_Disconnect("send failed: " .. safe_tostring(err)); return end + end +end + +function MOOSE_BRIDGE:_Tick() + if not self.Socket then self:_Connect() end + if self.Socket then + while true do + local line, err = self:_ReadLine() + if not line then break end + self:_HandleCommand(line) + end + self:_FlushOutQueue() + end + local now = mission_time() or 0 + if now - self.LastHeartbeat >= self.HeartbeatInterval then + self.LastHeartbeat = now + self:SendHeartbeat() + end +end diff --git a/Moose Development/Moose/Python/MooseBridgeAuftragExecutionExtension.lua b/Moose Development/Moose/Python/MooseBridgeAuftragExecutionExtension.lua new file mode 100644 index 000000000..4c0652265 --- /dev/null +++ b/Moose Development/Moose/Python/MooseBridgeAuftragExecutionExtension.lua @@ -0,0 +1,1776 @@ +-- Optional approval-gated AUFTRAG execution extension for MOOSE Bridge. +-- +-- Load after MooseBridge.lua and before creating the bridge instance. This file +-- exposes narrow, explicit AUFTRAG creation commands. Python should only call +-- them after an advisory recommendation has passed all hard filters and the user +-- explicitly requested execution. + +if not MOOSE_BRIDGE then error("Load MooseBridge.lua before MooseBridgeAuftragExecutionExtension.lua") end + +local bridge_unpack = table.unpack or unpack + +local function bridge_split_object_id(object_id) + if type(object_id) ~= "string" then return nil, nil end + local prefix, name = string.match(object_id, "^([^:]+):(.+)$") + if not prefix or not name then return nil, nil end + return string.upper(prefix), name +end + +local function bridge_safe_tostring(value) + if value == nil then return "nil" end + return tostring(value) +end + +local function bridge_table_keys(value) + if type(value) ~= "table" then return "<" .. type(value) .. ">" end + local keys = {} + for key, _ in pairs(value) do keys[#keys + 1] = tostring(key) end + table.sort(keys) + return table.concat(keys, ",") +end + +local function bridge_param_debug(command, params) + return "command_keys=[" .. bridge_table_keys(command) .. "] " .. + "params_keys=[" .. bridge_table_keys(command and command.params) .. "] " .. + "payload_keys=[" .. bridge_table_keys(command and command.payload) .. "] " .. + "resolved_keys=[" .. bridge_table_keys(params) .. "]" +end + +local function bridge_optional_string_param(value) + if type(value) ~= "string" then return nil end + local trimmed = string.match(value, "^%s*(.-)%s*$") + if trimmed == "" or string.lower(trimmed) == "null" then return nil end + return trimmed +end + +local function bridge_optional_bool(value) + if value == nil then return nil end + return value and true or false +end + +local function bridge_bool_param(value) + if value == nil then return nil end + if type(value) == "boolean" then return value end + local text = string.lower(tostring(value)) + if text == "true" or text == "1" or text == "yes" or text == "y" then return true end + if text == "false" or text == "0" or text == "no" or text == "n" then return false end + return value and true or false +end + +local function bridge_number_param(value) + if value == nil then return nil end + if type(value) == "number" then return value end + local text = bridge_optional_string_param(value) + if not text then return nil end + local number = tonumber(text) + if number == nil then error("Expected number parameter, got " .. bridge_safe_tostring(value)) end + return number +end + +local function bridge_coalition_param(value) + if value == nil then return nil end + if type(value) == "number" then return value end + local text = bridge_optional_string_param(value) + if not text then return nil end + local number = tonumber(text) + if number ~= nil then return number end + local normalized = string.lower(text) + if normalized == "red" then return coalition and coalition.side and coalition.side.RED or 1 end + if normalized == "blue" then return coalition and coalition.side and coalition.side.BLUE or 2 end + if normalized == "neutral" then return coalition and coalition.side and coalition.side.NEUTRAL or 0 end + error("Unknown coalition parameter: " .. bridge_safe_tostring(value)) +end + +local function bridge_time_param(value) + if value == nil then return nil end + if type(value) == "number" then return value end + return bridge_optional_string_param(value) +end + +local function bridge_auftrag_now() + if timer and timer.getAbsTime then return timer.getAbsTime() end + if timer and timer.getTime then return timer.getTime() end + return nil +end + +local function bridge_auftrag_summary(summary) + if type(summary) ~= "table" then return nil end + return { + success=bridge_optional_bool(summary.success), + Ntargets0=summary.Ntargets0, + Ntargets=summary.Ntargets, + damage=summary.damage, + Ndestroyed=summary.Ndestroyed, + Nkills=summary.Nkills, + Nelements=summary.Nelements, + targetLife=summary.targetLife, + category=summary.category, + Ncasualties=summary.Ncasualties, + } +end + +local function bridge_auftrag_ready_to_evaluate(auftrag) + local tover = auftrag and auftrag.Tover or nil + local dtevaluate = auftrag and auftrag.dTevaluate or nil + local now = bridge_auftrag_now() + if tover and dtevaluate and now then return now - tover >= dtevaluate end + return false +end + +function MOOSE_BRIDGE:_CommandParams(command) + if type(command) ~= "table" then return {} end + if type(command.params) == "table" then return command.params end + if type(command.payload) == "table" then + if type(command.payload.params) == "table" then return command.payload.params end + return command.payload + end + return {} +end + +function MOOSE_BRIDGE:_TrackAuftragReference(auftrag) + if type(auftrag) ~= "table" then return self end + local object_id = self:_AuftragObjectId(auftrag) + if not object_id then return self end + self.TrackedAuftraege = self.TrackedAuftraege or {} + self.TrackedAuftraege[object_id] = auftrag + return self +end + +function MOOSE_BRIDGE:_FindAuftragInQueueById(queue, auftrag_id) + if type(queue) ~= "table" then return nil end + for _, auftrag in pairs(queue) do + if self:_AuftragObjectId(auftrag) == auftrag_id then return auftrag end + end + return nil +end + +function MOOSE_BRIDGE:_ResolveTrackedAuftragById(auftrag_id) + local id = bridge_optional_string_param(auftrag_id) + if not id then return nil, "Missing AUFTRAG id" end + + if type(self.TrackedAuftraege) == "table" and type(self.TrackedAuftraege[id]) == "table" then + return self.TrackedAuftraege[id], nil + end + + if _DATABASE and type(_DATABASE.LEGIONS) == "table" then + for _, legion in pairs(_DATABASE.LEGIONS) do + local queues = {legion.missionqueue, legion.missions, legion.auftraege, legion.missionQueue} + for _, queue in ipairs(queues) do + local found = self:_FindAuftragInQueueById(queue, id) + if found then self:_TrackAuftragReference(found); return found, nil end + end + end + end + + for _, commander in pairs(self.RegisteredCommanders or {}) do + local found = self:_FindAuftragInQueueById(commander.missionqueue, id) + if found then self:_TrackAuftragReference(found); return found, nil end + end + + if _DATABASE and type(_DATABASE.FLIGHTGROUPS) == "table" then + for _, opsgroup in pairs(_DATABASE.FLIGHTGROUPS) do + local found = self:_FindAuftragInQueueById(opsgroup.missionqueue, id) + if found then self:_TrackAuftragReference(found); return found, nil end + end + end + + for _, opsgroup in pairs(self.RegisteredOpsGroups or {}) do + local found = self:_FindAuftragInQueueById(opsgroup.missionqueue, id) + if found then self:_TrackAuftragReference(found); return found, nil end + end + + return nil, "AUFTRAG not found: " .. id +end + +function MOOSE_BRIDGE:_CommandAuftragId(cmd) + local p = self:_CommandParams(cmd) + return bridge_optional_string_param(p.object_id) or bridge_optional_string_param(p.auftrag_id) +end + +function MOOSE_BRIDGE:_ResolveLegionById(legion_id) + local prefix, name = bridge_split_object_id(legion_id) + if prefix ~= "LEGION" or not name then return nil, "Invalid LEGION id " .. bridge_safe_tostring(legion_id) end + + if _DATABASE then + local found = self:_SafeCallArg(_DATABASE, "FindLegion", name) + if found then return found, nil end + + if type(_DATABASE.LEGIONS) == "table" then + for key, legion in pairs(_DATABASE.LEGIONS) do + local legion_name = self:_ObjectName(legion) or (type(key) == "string" and key or nil) + if legion_name == name then return legion, nil end + end + end + end + + return nil, "LEGION not found: " .. name +end + +function MOOSE_BRIDGE:_ResolveCommanderById(commander_id) + local prefix, name = bridge_split_object_id(commander_id) + if prefix ~= "COMMANDER" or not name then return nil, "Invalid COMMANDER id " .. bridge_safe_tostring(commander_id) end + + for key, commander in pairs(self.RegisteredCommanders or {}) do + local commander_name = self:_CommanderName(commander, type(key) == "string" and key or nil) + if commander_name == name then return commander, nil end + end + if _DATABASE and type(_DATABASE.COMMANDERS) == "table" then + for key, commander in pairs(_DATABASE.COMMANDERS) do + local commander_name = self:_CommanderName(commander, type(key) == "string" and key or nil) + if commander_name == name then return commander, nil end + end + end + return nil, "COMMANDER not found: " .. name +end + +function MOOSE_BRIDGE:_ResolveCohortById(cohort_id) + local prefix, name = bridge_split_object_id(cohort_id) + if prefix ~= "COHORT" or not name then return nil, "Invalid COHORT id " .. bridge_safe_tostring(cohort_id) end + if _DATABASE and type(_DATABASE.COHORTS) == "table" then + for key, cohort in pairs(_DATABASE.COHORTS) do + local cohort_name = self:_CohortName(cohort, type(key) == "string" and key or nil) + if cohort_name == name then return cohort, nil end + end + end + return nil, "COHORT not found: " .. name +end + +function MOOSE_BRIDGE:_ResolveOpsGroupById(opsgroup_id) + local prefix, name = bridge_split_object_id(opsgroup_id) + if prefix ~= "OPSGROUP" or not name then return nil, "Invalid OPSGROUP id " .. bridge_safe_tostring(opsgroup_id) end + + if type(self.RegisteredOpsGroups) == "table" then + for key, opsgroup in pairs(self.RegisteredOpsGroups) do + local opsgroup_name = self:_OpsName(opsgroup, type(key) == "string" and key or nil) + if opsgroup_name == name then return opsgroup, nil end + end + end + + if _DATABASE and type(_DATABASE.FLIGHTGROUPS) == "table" then + for key, opsgroup in pairs(_DATABASE.FLIGHTGROUPS) do + local opsgroup_name = self:_OpsName(opsgroup, type(key) == "string" and key or nil) + if opsgroup_name == name then return opsgroup, nil end + end + end + + return nil, "OPSGROUP not found: " .. name +end + +function MOOSE_BRIDGE:_ResolveAuftragTargetById(target_id) + local prefix, name = bridge_split_object_id(target_id) + if not prefix or not name then return nil, "Invalid target id " .. bridge_safe_tostring(target_id) end + + if prefix == "GROUP" then + if GROUP and GROUP.FindByName then + local target = GROUP:FindByName(name) + if target then return target, nil end + end + if Group and Group.getByName then + local dcs_group = Group.getByName(name) + if dcs_group then return dcs_group, nil end + end + return nil, "GROUP target not found: " .. name + end + + if prefix == "UNIT" then + if UNIT and UNIT.FindByName then + local target = UNIT:FindByName(name) + if target then return target, nil end + end + if Unit and Unit.getByName then + local dcs_unit = Unit.getByName(name) + if dcs_unit then return dcs_unit, nil end + end + return nil, "UNIT target not found: " .. name + end + + if prefix == "STATIC" then + if STATIC and STATIC.FindByName then + local target = STATIC:FindByName(name) + if target then return target, nil end + end + if StaticObject and StaticObject.getByName then + local dcs_static = StaticObject.getByName(name) + if dcs_static then return dcs_static, nil end + end + return nil, "STATIC target not found: " .. name + end + + return nil, "Unsupported AUFTRAG target type: " .. prefix +end + +function MOOSE_BRIDGE:_ResolveCoordinateFromInputs(inputs) + local x = inputs.x ~= nil and tonumber(inputs.x) or nil + local z = inputs.z ~= nil and tonumber(inputs.z) or nil + local y = inputs.y ~= nil and tonumber(inputs.y) or 0 + + if not x or not z then return nil, "Coordinate target requires numeric x and z" end + if not COORDINATE or not COORDINATE.New then return nil, "COORDINATE:New is not available" end + + return COORDINATE:New(x, y, z), nil +end + +function MOOSE_BRIDGE:_BuildOpsZoneSnapshotItem(zone_name, opszone, source) + local name = self:_OpsName(opszone, zone_name) + if not name then return nil end + local point = self:_PointFromMooseObject(opszone) + local state = self:_OpsState(opszone) + local n_red = opszone and tonumber(opszone.Nred) or 0 + local n_blue = opszone and tonumber(opszone.Nblu) or 0 + local item = { + object_id="OPSZONE:"..bridge_safe_tostring(name), + dcs_name=bridge_safe_tostring(name), + object_type="OPSZONE", + category=self:_OpsClassName(opszone, "OPSZONE"), + source=source, + name=bridge_safe_tostring(name), + zone_name=opszone and opszone.zoneName and tostring(opszone.zoneName) or nil, + zone_type=opszone and opszone.zoneType and tostring(opszone.zoneType) or nil, + zone_radius=opszone and opszone.zoneRadius or nil, + state=state and tostring(state) or nil, + owner_current_name=self:_CoalitionToName(opszone and opszone.ownerCurrent), + owner_previous_name=self:_CoalitionToName(opszone and opszone.ownerPrevious), + is_contested=n_red > 0 and n_blue > 0, + n_red=n_red, + n_blue=n_blue, + n_neutral=opszone and opszone.Nnut or 0, + threat_red=opszone and opszone.Tred or 0, + threat_blue=opszone and opszone.Tblu or 0, + threat_neutral=opszone and opszone.Tnut or 0, + airbase_name=opszone and opszone.airbaseName and tostring(opszone.airbaseName) or nil, + capture_event_callback_type=opszone and type(opszone.OnAfterCaptured) or "nil", + capture_event_forwarder_attached=opszone and + opszone.MooseBridgeCapturedEventForwarder ~= nil and + opszone.OnAfterCaptured == opszone.MooseBridgeCapturedEventForwarder or false, + } + if point then self:_AddPointFields(item, point) end + return item +end + +--- Forward the MOOSE OPSZONE Captured event through its public callback. +-- @param Ops.OpsZone#OPSZONE opszone OPSZONE instance. +-- @param #string zone_name Registered zone name. +-- @return #MOOSE_BRIDGE self +function MOOSE_BRIDGE:_AttachOpsZoneEventForwarder(opszone, zone_name) + if type(opszone) ~= "table" then return self end + if opszone.MooseBridgeCapturedEventForwarder and + opszone.OnAfterCaptured == opszone.MooseBridgeCapturedEventForwarder then + return self + end + local bridge = self + local user_callback = opszone.OnAfterCaptured + local forwarder = function(opszone_self, From, Event, To, Coalition) + if type(user_callback) == "function" then + user_callback(opszone_self, From, Event, To, Coalition) + end + local item = bridge:_BuildOpsZoneSnapshotItem(zone_name, opszone_self, "event") + if item and item.object_id then + bridge:SendEvent("opszone.owner_changed", { + opszone_id=item.object_id, + previous_coalition=item.owner_previous_name, + coalition=item.owner_current_name, + capturing_coalition=bridge:_CoalitionToName(Coalition), + fsm_event=Event, + from_state=From, + to_state=To, + opszone=item, + }) + end + end + opszone.OnAfterCaptured = forwarder + opszone.MooseBridgeCapturedEventForwarder = forwarder + return self +end + +function MOOSE_BRIDGE:BuildOpsZoneSnapshot() + local result = {}; local seen = {} + for name, opszone in pairs(self.RegisteredOpsZones or {}) do + self:_AttachOpsZoneEventForwarder(opszone, name) + local ok, item = pcall(function() return self:_BuildOpsZoneSnapshotItem(name, opszone, "registered") end) + if ok and item and item.object_id then result[#result + 1] = item; seen[item.object_id] = true end + end + if _DATABASE and type(_DATABASE.OPSZONES) == "table" then + for name, opszone in pairs(_DATABASE.OPSZONES) do + self:_AttachOpsZoneEventForwarder(opszone, name) + local ok, item = pcall(function() return self:_BuildOpsZoneSnapshotItem(name, opszone, "database.OPSZONES") end) + if ok and item and item.object_id and not seen[item.object_id] then result[#result + 1] = item; seen[item.object_id] = true end + end + end + return result +end + +function MOOSE_BRIDGE:_CoordinateTargetFromObjectId(target_id) + local prefix, name = bridge_split_object_id(target_id) + if not prefix or not name then return nil, "Invalid coordinate target id " .. bridge_safe_tostring(target_id) end + + local point = nil + if prefix == "OPSZONE" then + point = self:_PointForOpsZoneName(name) + elseif prefix == "SCENERY" then + return nil, "SCENERY coordinate target resolution is not available yet" + else + local ok, value = pcall(function() return self:_PointForObjectId(target_id) end) + if ok then point = value else return nil, bridge_safe_tostring(value) end + end + + if not point then return nil, "Coordinate target point not found for " .. bridge_safe_tostring(target_id) end + + local ok_coordinate, coordinate = pcall(function() return self:_CoordinateFromPoint(point) end) + if ok_coordinate then return coordinate, nil end + return nil, bridge_safe_tostring(coordinate) +end + +function MOOSE_BRIDGE:_ResolveCoordinateAuftragTarget(inputs) + if inputs.target_id then + return self:_CoordinateTargetFromObjectId(inputs.target_id) + end + return self:_ResolveCoordinateFromInputs(inputs) +end + +function MOOSE_BRIDGE:_CommonAuftragCommandInputs(cmd) + local p = self:_CommandParams(cmd) + local legacy_params = type(p.params) == "table" and p.params or {} + local inputs = { + params=p, + commander_id=bridge_optional_string_param(p.commander_id) or bridge_optional_string_param(legacy_params.commander_id), + legion_id=bridge_optional_string_param(p.legion_id) or bridge_optional_string_param(legacy_params.legion_id), + opsgroup_id=bridge_optional_string_param(p.opsgroup_id) or bridge_optional_string_param(legacy_params.opsgroup_id), + cohort_id=bridge_optional_string_param(p.cohort_id) or bridge_optional_string_param(legacy_params.cohort_id), + allowed_legion_ids=p.allowed_legion_ids or legacy_params.allowed_legion_ids, + allowed_cohort_ids=p.allowed_cohort_ids or legacy_params.allowed_cohort_ids, + clock_start=bridge_time_param(p.clock_start) or bridge_time_param(p.ClockStart) or bridge_time_param(legacy_params.clock_start) or bridge_time_param(legacy_params.ClockStart), + clock_stop=bridge_time_param(p.clock_stop) or bridge_time_param(p.ClockStop) or bridge_time_param(legacy_params.clock_stop) or bridge_time_param(legacy_params.ClockStop), + duration=bridge_number_param(p.duration) or bridge_number_param(p.Duration) or bridge_number_param(legacy_params.duration) or bridge_number_param(legacy_params.Duration), + required_assets_min=bridge_number_param(p.required_assets_min) or bridge_number_param(p.nassets_min) or bridge_number_param(p.NassetsMin) or bridge_number_param(legacy_params.required_assets_min) or bridge_number_param(legacy_params.nassets_min) or bridge_number_param(legacy_params.NassetsMin), + required_assets_max=bridge_number_param(p.required_assets_max) or bridge_number_param(p.nassets_max) or bridge_number_param(p.NassetsMax) or bridge_number_param(legacy_params.required_assets_max) or bridge_number_param(legacy_params.nassets_max) or bridge_number_param(legacy_params.NassetsMax), + weapon_type=bridge_number_param(p.weapon_type) or bridge_number_param(p.WeaponType) or bridge_number_param(legacy_params.weapon_type) or bridge_number_param(legacy_params.WeaponType), + target_id=bridge_optional_string_param(p.target) or bridge_optional_string_param(legacy_params.target), + opszone_id=bridge_optional_string_param(p.opszone) or bridge_optional_string_param(p.opszone_id) or bridge_optional_string_param(legacy_params.opszone) or bridge_optional_string_param(legacy_params.opszone_id), + capture_coalition=bridge_coalition_param(p.capture_coalition) or bridge_coalition_param(p.capture_coalition_id) or bridge_coalition_param(p.CaptureCoalition) or bridge_coalition_param(legacy_params.capture_coalition) or bridge_coalition_param(legacy_params.capture_coalition_id) or bridge_coalition_param(legacy_params.CaptureCoalition), + stay_in_zone_time_s=bridge_number_param(p.stay_in_zone_time_s) or bridge_number_param(p.stay_in_zone_time) or bridge_number_param(p.StayInZoneTime) or bridge_number_param(legacy_params.stay_in_zone_time_s) or bridge_number_param(legacy_params.stay_in_zone_time) or bridge_number_param(legacy_params.StayInZoneTime), + zone_id=bridge_optional_string_param(p.zone) or bridge_optional_string_param(p.zone_id) or bridge_optional_string_param(legacy_params.zone) or bridge_optional_string_param(legacy_params.zone_id), + zones=p.zones or p.zone_ids or legacy_params.zones or legacy_params.zone_ids, + coordinate_id=bridge_optional_string_param(p.coordinate) or bridge_optional_string_param(p.coordinate_id) or bridge_optional_string_param(legacy_params.coordinate) or bridge_optional_string_param(legacy_params.coordinate_id), + dropoff_id=bridge_optional_string_param(p.dropoff) or bridge_optional_string_param(p.dropoff_id) or bridge_optional_string_param(legacy_params.dropoff) or bridge_optional_string_param(legacy_params.dropoff_id), + pickup_id=bridge_optional_string_param(p.pickup) or bridge_optional_string_param(p.pickup_id) or bridge_optional_string_param(legacy_params.pickup) or bridge_optional_string_param(legacy_params.pickup_id), + x=p.x or legacy_params.x, + y=p.y or legacy_params.y, + z=p.z or legacy_params.z, + dropoff_x=p.dropoff_x or legacy_params.dropoff_x, + dropoff_y=p.dropoff_y or legacy_params.dropoff_y, + dropoff_z=p.dropoff_z or legacy_params.dropoff_z, + pickup_x=p.pickup_x or legacy_params.pickup_x, + pickup_y=p.pickup_y or legacy_params.pickup_y, + pickup_z=p.pickup_z or legacy_params.pickup_z, + altitude_ft=p.altitude_ft or legacy_params.altitude_ft, + selected_payload_uid=p.selected_payload_uid or legacy_params.selected_payload_uid, + engage_weapon_type=p.engage_weapon_type or p.EngageWeaponType or legacy_params.engage_weapon_type or legacy_params.EngageWeaponType, + divebomb=p.divebomb, + nshots=p.nshots or p.Nshots or legacy_params.nshots or legacy_params.Nshots, + radius_m=p.radius_m or p.radius or p.Radius or legacy_params.radius_m or legacy_params.radius or legacy_params.Radius, + carpet_length_m=p.carpet_length_m or p.carpet_length or p.CarpetLength or legacy_params.carpet_length_m or legacy_params.carpet_length or legacy_params.CarpetLength, + length_m=p.length_m or p.length or p.Length or legacy_params.length_m or legacy_params.length or legacy_params.Length, + speed_kts=p.speed_kts or p.speed or p.Speed or legacy_params.speed_kts or legacy_params.speed or legacy_params.Speed, + formation=bridge_optional_string_param(p.formation) or bridge_optional_string_param(p.Formation) or bridge_optional_string_param(legacy_params.formation) or bridge_optional_string_param(legacy_params.Formation), + depth_m=p.depth_m or p.depth or p.Depth or legacy_params.depth_m or legacy_params.depth or legacy_params.Depth, + heading_deg=p.heading_deg or p.heading or p.Heading or legacy_params.heading_deg or legacy_params.heading or legacy_params.Heading, + leg_nm=p.leg_nm or p.leg or p.Leg or legacy_params.leg_nm or legacy_params.leg or legacy_params.Leg, + refuel_system=p.refuel_system or p.RefuelSystem or legacy_params.refuel_system or legacy_params.RefuelSystem, + target_types=p.target_types or p.TargetTypes or legacy_params.target_types or legacy_params.TargetTypes, + range_max_nm=p.range_max_nm or p.range_max or p.RangeMax or legacy_params.range_max_nm or legacy_params.range_max or legacy_params.RangeMax, + orbit_distance_nm=p.orbit_distance_nm or p.orbit_distance or p.OrbitDistance or legacy_params.orbit_distance_nm or legacy_params.orbit_distance or legacy_params.OrbitDistance, + engage_max_distance_nm=p.engage_max_distance_nm or p.engage_max_distance or p.EngageMaxDistance or legacy_params.engage_max_distance_nm or legacy_params.engage_max_distance or legacy_params.EngageMaxDistance, + offset_x=p.offset_x or legacy_params.offset_x, + offset_y=p.offset_y or legacy_params.offset_y, + offset_z=p.offset_z or legacy_params.offset_z, + transport_groups=p.transport_groups or p.transport_group or p.TransportGroups or legacy_params.transport_groups or legacy_params.transport_group or legacy_params.TransportGroups, + pickup_radius_m=p.pickup_radius_m or p.pickup_radius or p.PickupRadius or legacy_params.pickup_radius_m or legacy_params.pickup_radius or legacy_params.PickupRadius, + no_engage_zones=p.no_engage_zones or p.no_engage_zone or p.NoEngageZones or legacy_params.no_engage_zones or legacy_params.no_engage_zone or legacy_params.NoEngageZones, + frequency_mhz=p.frequency_mhz or p.frequency or p.Frequency or legacy_params.frequency_mhz or legacy_params.frequency or legacy_params.Frequency, + modulation=p.modulation or p.Modulation or legacy_params.modulation or legacy_params.Modulation, + designation=bridge_optional_string_param(p.designation) or bridge_optional_string_param(p.Designation) or bridge_optional_string_param(legacy_params.designation) or bridge_optional_string_param(legacy_params.Designation), + data_link=p.data_link, + ad_infinitum=p.ad_infinitum, + randomly=p.randomly, + } + if inputs.divebomb == nil then inputs.divebomb = legacy_params.divebomb end + if inputs.data_link == nil then inputs.data_link = p.datalink end + if inputs.data_link == nil then inputs.data_link = p.DataLink end + if inputs.data_link == nil then inputs.data_link = legacy_params.data_link end + if inputs.data_link == nil then inputs.data_link = legacy_params.datalink end + if inputs.data_link == nil then inputs.data_link = legacy_params.DataLink end + if inputs.ad_infinitum == nil then inputs.ad_infinitum = p.Adinfinitum end + if inputs.ad_infinitum == nil then inputs.ad_infinitum = legacy_params.ad_infinitum end + if inputs.ad_infinitum == nil then inputs.ad_infinitum = legacy_params.Adinfinitum end + if inputs.randomly == nil then inputs.randomly = p.Randomly end + if inputs.randomly == nil then inputs.randomly = legacy_params.randomly end + if inputs.randomly == nil then inputs.randomly = legacy_params.Randomly end + + local target_count = (inputs.commander_id and 1 or 0) + (inputs.legion_id and 1 or 0) + (inputs.opsgroup_id and 1 or 0) + if target_count ~= 1 then error("Specify exactly one of commander_id, legion_id or opsgroup_id; " .. bridge_param_debug(cmd, p)) end + + inputs.allowed_legion_ids = self:_NormalizeStringList(inputs.allowed_legion_ids) or {} + inputs.allowed_cohort_ids = self:_NormalizeStringList(inputs.allowed_cohort_ids) or {} + if inputs.cohort_id then inputs.allowed_cohort_ids[#inputs.allowed_cohort_ids + 1] = inputs.cohort_id end + if inputs.opsgroup_id and (#inputs.allowed_legion_ids > 0 or #inputs.allowed_cohort_ids > 0) then + error("LEGION or COHORT constraints cannot be used with opsgroup_id") + end + if inputs.legion_id and #inputs.allowed_legion_ids > 0 then + error("allowed_legion_ids requires commander_id") + end + + if inputs.commander_id then + local commander, commander_err = self:_ResolveCommanderById(inputs.commander_id) + if not commander then error(commander_err) end + inputs.commander = commander + end + + if inputs.legion_id then + local legion, legion_err = self:_ResolveLegionById(inputs.legion_id) + if not legion then error(legion_err) end + inputs.legion = legion + end + + if inputs.opsgroup_id then + local opsgroup, opsgroup_err = self:_ResolveOpsGroupById(inputs.opsgroup_id) + if not opsgroup then error(opsgroup_err) end + inputs.opsgroup = opsgroup + end + + inputs.allowed_legions = {} + for _, legion_id in ipairs(inputs.allowed_legion_ids) do + local legion, legion_err = self:_ResolveLegionById(legion_id) + if not legion then error(legion_err) end + inputs.allowed_legions[#inputs.allowed_legions + 1] = legion + end + inputs.allowed_cohorts = {} + for _, cohort_id in ipairs(inputs.allowed_cohort_ids) do + local cohort, cohort_err = self:_ResolveCohortById(cohort_id) + if not cohort then error(cohort_err) end + inputs.allowed_cohorts[#inputs.allowed_cohorts + 1] = cohort + end + + return inputs +end + +function MOOSE_BRIDGE:_ResolveObjectAuftragTarget(inputs) + if not inputs.target_id then return nil, "Missing target" end + return self:_ResolveAuftragTargetById(inputs.target_id) +end + +function MOOSE_BRIDGE:_ResolveGroupAuftragTarget(inputs) + if not inputs.target_id then return nil, "Missing target" end + local prefix = bridge_split_object_id(inputs.target_id) + if prefix ~= "GROUP" then return nil, "Target must be GROUP:" end + return self:_ResolveAuftragTargetById(inputs.target_id) +end + +function MOOSE_BRIDGE:_ResolveGroupOrUnitAuftragTarget(inputs) + if not inputs.target_id then return nil, "Missing target" end + local prefix = bridge_split_object_id(inputs.target_id) + if prefix ~= "GROUP" and prefix ~= "UNIT" then return nil, "Target must be GROUP: or UNIT:" end + return self:_ResolveAuftragTargetById(inputs.target_id) +end + +function MOOSE_BRIDGE:_ResolvePositionableAttackAuftragTarget(inputs) + if not inputs.target_id then return nil, "Missing target" end + local prefix = bridge_split_object_id(inputs.target_id) + if prefix ~= "GROUP" and prefix ~= "UNIT" and prefix ~= "STATIC" then return nil, "Target must be GROUP:, UNIT: or STATIC:" end + return self:_ResolveAuftragTargetById(inputs.target_id) +end + +function MOOSE_BRIDGE:_ResolveUnitAuftragTarget(inputs) + if not inputs.target_id then return nil, "Missing target" end + local prefix = bridge_split_object_id(inputs.target_id) + if prefix ~= "UNIT" then return nil, "Target must be UNIT:" end + return self:_ResolveAuftragTargetById(inputs.target_id) +end + +function MOOSE_BRIDGE:_ResolveAirbaseAuftragTarget(inputs) + if not inputs.target_id then return nil, "Missing target" end + local prefix, name = bridge_split_object_id(inputs.target_id) + if prefix ~= "AIRBASE" then return nil, "BOMBRUNWAY target must be AIRBASE:" end + + if AIRBASE and AIRBASE.FindByName then + local ok, airbase = pcall(function() return AIRBASE:FindByName(name) end) + if ok and airbase then return airbase, nil end + end + + return nil, "AIRBASE target not found: " .. bridge_safe_tostring(name) +end + +function MOOSE_BRIDGE:_ResolveBombingTarget(inputs) + return self:_ResolveCoordinateAuftragTarget(inputs) +end + +function MOOSE_BRIDGE:_ResolveBombCarpetTarget(inputs) + if inputs.target_id then + local prefix = bridge_split_object_id(inputs.target_id) + if prefix ~= "GROUP" and prefix ~= "UNIT" and prefix ~= "STATIC" then + return nil, "BOMBCARPET target must be GROUP:, UNIT:, STATIC: or direct x/z coordinates" + end + end + return self:_ResolveCoordinateAuftragTarget(inputs) +end + +function MOOSE_BRIDGE:_ResolveStrafingTarget(inputs) + if inputs.target_id then + local prefix = bridge_split_object_id(inputs.target_id) + if prefix ~= "GROUP" and prefix ~= "UNIT" and prefix ~= "STATIC" then + return nil, "STRAFING target must be GROUP:, UNIT:, STATIC: or direct x/z coordinates" + end + end + return self:_ResolveCoordinateAuftragTarget(inputs) +end + +function MOOSE_BRIDGE:_ResolveArtyTarget(inputs) + return self:_ResolveCoordinateAuftragTarget(inputs) +end + +function MOOSE_BRIDGE:_ResolveStrikeTarget(inputs) + return self:_ResolveCoordinateAuftragTarget(inputs) +end + +function MOOSE_BRIDGE:_ResolveOrbitTarget(inputs) + return self:_ResolveCoordinateAuftragTarget(inputs) +end + +function MOOSE_BRIDGE:_ResolveOnGuardTarget(inputs) + return self:_ResolveCoordinateAuftragTarget(inputs) +end + +function MOOSE_BRIDGE:_ResolveZonePatrolZone(inputs) + if not inputs.zone_id then return nil, "Missing zone" end + local ok, zone = pcall(function() return self:_ZoneForDrawObjectId(inputs.zone_id) end) + if ok and zone then return zone, nil end + return nil, bridge_safe_tostring(zone) +end + +function MOOSE_BRIDGE:_ResolveCaptureOpsZone(inputs) + local opszone_id = inputs.opszone_id or inputs.zone_id or inputs.target_id + if not opszone_id then return nil, "Missing opszone" end + local prefix, name = bridge_split_object_id(opszone_id) + if prefix ~= "OPSZONE" then return nil, "CAPTUREZONE requires OPSZONE:, got " .. bridge_safe_tostring(opszone_id) end + + local opszone = self.RegisteredOpsZones and self.RegisteredOpsZones[name] + if not opszone and _DATABASE and type(_DATABASE.OPSZONES) == "table" then opszone = _DATABASE.OPSZONES[name] end + if not opszone then return nil, "OPSZONE not found: " .. bridge_safe_tostring(opszone_id) end + inputs.opszone_id = opszone_id + return opszone, nil +end + +function MOOSE_BRIDGE:_ResolveZonePatrolCoordinate(inputs) + if inputs.coordinate_id then + return self:_CoordinateTargetFromObjectId(inputs.coordinate_id) + end + if inputs.x ~= nil or inputs.z ~= nil then + return self:_ResolveCoordinateFromInputs(inputs) + end + return nil, nil +end + +function MOOSE_BRIDGE:_NormalizeTargetTypes(value) + if type(value) == "table" then return value end + if type(value) ~= "string" or value == "" then return nil end + + local result = {} + for item in string.gmatch(value, "([^,]+)") do + item = item:gsub("^%s+", ""):gsub("%s+$", "") + if item ~= "" then result[#result + 1] = item end + end + if #result > 0 then return result end + return nil +end + +function MOOSE_BRIDGE:_BuildOffsetVector(inputs) + if inputs.offset_x == nil and inputs.offset_y == nil and inputs.offset_z == nil then return nil end + return { + x=inputs.offset_x ~= nil and tonumber(inputs.offset_x) or 0, + y=inputs.offset_y ~= nil and tonumber(inputs.offset_y) or 0, + z=inputs.offset_z ~= nil and tonumber(inputs.offset_z) or 0, + } +end + +function MOOSE_BRIDGE:_ResolveCoordinateFromNamedFields(object_id, x_value, y_value, z_value, label, required) + if object_id then + return self:_CoordinateTargetFromObjectId(object_id) + end + if x_value ~= nil or z_value ~= nil then + return self:_ResolveCoordinateFromInputs({x=x_value, y=y_value, z=z_value}) + end + if required then return nil, label .. " coordinate requires object id or numeric x and z" end + return nil, nil +end + +function MOOSE_BRIDGE:_NormalizeStringList(value) + if type(value) == "table" then return value end + if type(value) ~= "string" or value == "" then return nil end + + local result = {} + for item in string.gmatch(value, "([^,]+)") do + item = item:gsub("^%s+", ""):gsub("%s+$", "") + if item ~= "" then result[#result + 1] = item end + end + if #result > 0 then return result end + return nil +end + +function MOOSE_BRIDGE:_BuildGroupSet(value) + local group_ids = self:_NormalizeStringList(value) + if not group_ids or #group_ids == 0 then error("TransportGroupSet requires at least one GROUP") end + if not SET_GROUP or not SET_GROUP.New then error("SET_GROUP:New is not available") end + + local set_group = SET_GROUP:New() + for _, group_id in ipairs(group_ids) do + local inputs = {target_id=group_id} + local group, group_err = self:_ResolveGroupAuftragTarget(inputs) + if not group then error(group_err) end + + if type(set_group.AddGroup) == "function" then + set_group:AddGroup(group) + elseif type(set_group.Add) == "function" then + set_group:Add(group) + elseif type(set_group.AddObject) == "function" then + set_group:AddObject(group) + else + error("SET_GROUP add method is not available") + end + end + + return set_group, group_ids +end + +function MOOSE_BRIDGE:_CallAuftragLifecycleMethod(cmd, action, method_name) + local auftrag_id = self:_CommandAuftragId(cmd) + local auftrag, auftrag_err = self:_ResolveTrackedAuftragById(auftrag_id) + if not auftrag then error(auftrag_err) end + if type(auftrag[method_name]) ~= "function" then error("AUFTRAG:" .. method_name .. " is not available for " .. bridge_safe_tostring(auftrag_id)) end + + local ok, result = pcall(function() return auftrag[method_name](auftrag) end) + if not ok then error("AUFTRAG:" .. method_name .. " failed: " .. bridge_safe_tostring(result)) end + self:_TrackAuftragReference(auftrag) + + return { + action=action, + auftrag_id=auftrag_id, + auftragsnummer=self:_AuftragNumber(auftrag), + auftrag_type=self:_SafeCall(auftrag, "GetType") or auftrag.type, + status=self:_SafeCall(auftrag, "GetState") or self:_SafeCall(auftrag, "GetStatus"), + } +end + +function MOOSE_BRIDGE:_AssignTrackedAuftrag(cmd) + local inputs = self:_CommonAuftragCommandInputs(cmd) + local auftrag_id = self:_CommandAuftragId(cmd) + local auftrag, auftrag_err = self:_ResolveTrackedAuftragById(auftrag_id) + if not auftrag then error(auftrag_err) end + + self:_AddAuftragToTarget(auftrag, inputs) + return self:_BuildAuftragCommandResult("auftrag.assign", auftrag, inputs) +end + +function MOOSE_BRIDGE:_SetCohortWeaponRange(cmd) + local p = self:_CommandParams(cmd) + local cohort_id = bridge_optional_string_param(p.cohort_id) + local weapon_type = bridge_number_param(p.weapon_type) + local minimum_m = bridge_number_param(p.minimum_m) + local maximum_m = bridge_number_param(p.maximum_m) + if not cohort_id then error("cohort.set_weapon_range requires cohort_id") end + if weapon_type == nil then error("cohort.set_weapon_range requires weapon_type") end + if minimum_m == nil then minimum_m = 0 end + if maximum_m == nil or maximum_m <= 0 then error("cohort.set_weapon_range requires positive maximum_m") end + if minimum_m < 0 or minimum_m > maximum_m then error("Invalid cohort weapon range") end + + local cohort, cohort_err = self:_ResolveCohortById(cohort_id) + if not cohort then error(cohort_err) end + if type(cohort.AddWeaponRange) ~= "function" then error("COHORT:AddWeaponRange is not available") end + + local previous = type(cohort.weaponData) == "table" and cohort.weaponData[bridge_safe_tostring(weapon_type)] or nil + local ok, err = pcall(function() + return cohort:AddWeaponRange(minimum_m / 1852, maximum_m / 1852, weapon_type) + end) + if not ok then error("COHORT:AddWeaponRange failed: " .. bridge_safe_tostring(err)) end + + return { + action="cohort.set_weapon_range", + cohort_id=cohort_id, + weapon_type=weapon_type, + minimum_m=minimum_m, + maximum_m=maximum_m, + previous_minimum_m=previous and previous.RangeMin or nil, + previous_maximum_m=previous and previous.RangeMax or nil, + mission_range_m=self:_SafeCallArg(cohort, "GetMissionRange", {weapon_type}), + } +end + +function MOOSE_BRIDGE:_BuildZoneSet(value, label, required) + local zone_ids = self:_NormalizeStringList(value) + if not zone_ids or #zone_ids == 0 then + if required then error(label .. " requires at least one ZONE") end + return nil, nil + end + if not SET_ZONE or not SET_ZONE.New then error("SET_ZONE:New is not available") end + + local set_zone = SET_ZONE:New() + for _, zone_id in ipairs(zone_ids) do + local ok_zone, zone = pcall(function() return self:_ZoneForDrawObjectId(zone_id) end) + if not ok_zone or not zone then error(label .. " zone not found: " .. bridge_safe_tostring(zone_id)) end + + if type(set_zone.AddZone) == "function" then + set_zone:AddZone(zone) + elseif type(set_zone.Add) == "function" then + set_zone:Add(zone) + elseif type(set_zone.AddObject) == "function" then + set_zone:AddObject(zone) + else + error("SET_ZONE add method is not available") + end + end + + return set_zone, zone_ids +end + +function MOOSE_BRIDGE:_BuildNoEngageZoneSet(value) + return self:_BuildZoneSet(value, "NoEngage", false) +end + +function MOOSE_BRIDGE:_AddAuftragToLegion(auftrag, inputs) + if not auftrag then error("AUFTRAG constructor returned nil") end + self:_ApplyAuftragAssignments(auftrag, inputs) + self:_ApplyAuftragTiming(auftrag, inputs) + self:_RegisterAuftragEvents(auftrag, inputs) + self:_SendAuftragStatusEvent(auftrag, inputs, "Planned") + local add_ok, add_result = pcall(function() return inputs.legion:AddMission(auftrag) end) + if not add_ok then error("LEGION:AddMission failed: " .. bridge_safe_tostring(add_result)) end + self:_TrackAuftragReference(auftrag) + return auftrag +end + +function MOOSE_BRIDGE:_AddAuftragToCommander(auftrag, inputs) + if not auftrag then error("AUFTRAG constructor returned nil") end + if type(inputs.commander.AddMission) ~= "function" then error("COMMANDER:AddMission is not available") end + self:_ApplyAuftragAssignments(auftrag, inputs) + self:_ApplyAuftragTiming(auftrag, inputs) + self:_RegisterAuftragEvents(auftrag, inputs) + self:_SendAuftragStatusEvent(auftrag, inputs, "Planned") + local add_ok, add_result = pcall(function() return inputs.commander:AddMission(auftrag) end) + if not add_ok then error("COMMANDER:AddMission failed: " .. bridge_safe_tostring(add_result)) end + self:_TrackAuftragReference(auftrag) + return auftrag +end + +function MOOSE_BRIDGE:_AddAuftragToOpsGroup(auftrag, inputs) + if not auftrag then error("AUFTRAG constructor returned nil") end + if type(inputs.opsgroup.AddMission) ~= "function" then error("OPSGROUP:AddMission is not available") end + self:_ApplyAuftragTiming(auftrag, inputs) + self:_RegisterAuftragEvents(auftrag, inputs) + self:_SendAuftragStatusEvent(auftrag, inputs, "Planned") + local add_ok, add_result = pcall(function() return inputs.opsgroup:AddMission(auftrag) end) + if not add_ok then error("OPSGROUP:AddMission failed: " .. bridge_safe_tostring(add_result)) end + self:_TrackAuftragReference(auftrag) + return auftrag +end + +function MOOSE_BRIDGE:_ApplyAuftragAssignments(auftrag, inputs) + if not auftrag or not inputs then return auftrag end + for _, legion in ipairs(inputs.allowed_legions or {}) do + if type(auftrag.AssignLegion) ~= "function" then error("AUFTRAG:AssignLegion is not available") end + local ok, err = pcall(function() return auftrag:AssignLegion(legion) end) + if not ok then error("AUFTRAG:AssignLegion failed: " .. bridge_safe_tostring(err)) end + end + for _, cohort in ipairs(inputs.allowed_cohorts or {}) do + if type(auftrag.AssignCohort) ~= "function" then error("AUFTRAG:AssignCohort is not available") end + local ok, err = pcall(function() return auftrag:AssignCohort(cohort) end) + if not ok then error("AUFTRAG:AssignCohort failed: " .. bridge_safe_tostring(err)) end + end + return auftrag +end + +function MOOSE_BRIDGE:_ApplyAuftragTiming(auftrag, inputs) + if not auftrag or not inputs then return auftrag end + + if inputs.clock_start ~= nil or inputs.clock_stop ~= nil then + if type(auftrag.SetTime) ~= "function" then error("AUFTRAG:SetTime is not available") end + local time_ok, time_err = pcall(function() return auftrag:SetTime(inputs.clock_start, inputs.clock_stop) end) + if not time_ok then error("AUFTRAG:SetTime failed: " .. bridge_safe_tostring(time_err)) end + end + + if inputs.duration ~= nil then + if type(auftrag.SetDuration) ~= "function" then error("AUFTRAG:SetDuration is not available") end + local duration_ok, duration_err = pcall(function() return auftrag:SetDuration(inputs.duration) end) + if not duration_ok then error("AUFTRAG:SetDuration failed: " .. bridge_safe_tostring(duration_err)) end + end + + if inputs.required_assets_min ~= nil or inputs.required_assets_max ~= nil then + if type(auftrag.SetRequiredAssets) ~= "function" then error("AUFTRAG:SetRequiredAssets is not available") end + local nassets_min = inputs.required_assets_min + if nassets_min == nil then nassets_min = 1 end + local assets_ok, assets_err = pcall(function() return auftrag:SetRequiredAssets(nassets_min, inputs.required_assets_max) end) + if not assets_ok then error("AUFTRAG:SetRequiredAssets failed: " .. bridge_safe_tostring(assets_err)) end + end + + if inputs.weapon_type ~= nil then + if type(auftrag.SetWeaponType) ~= "function" then error("AUFTRAG:SetWeaponType is not available") end + local weapon_ok, weapon_err = pcall(function() return auftrag:SetWeaponType(inputs.weapon_type) end) + if not weapon_ok then error("AUFTRAG:SetWeaponType failed: " .. bridge_safe_tostring(weapon_err)) end + end + + return auftrag +end + +function MOOSE_BRIDGE:_SendAuftragStatusEvent(auftrag, inputs, fsm_event, From, Event, To) + if type(auftrag) ~= "table" then return end + local object_id = self:_AuftragObjectId(auftrag) + local ok, err = pcall(function() + self:SendEvent("auftrag.status", { + auftrag_id=object_id, + auftragsnummer=self:_AuftragNumber(auftrag), + auftrag_type=self:_SafeCall(auftrag, "GetType") or auftrag.type, + status=self:_SafeCall(auftrag, "GetState") or self:_SafeCall(auftrag, "GetStatus") or To, + fsm_event=fsm_event, + from=From, + fsm_event_name=Event, + to=To, + commander_id=inputs and inputs.commander_id or nil, + legion_id=inputs and inputs.legion_id or nil, + opsgroup_id=inputs and inputs.opsgroup_id or nil, + cohort_id=inputs and inputs.cohort_id or nil, + target=inputs and (inputs.target_id or inputs.zone_id) or nil, + }) + end) + if not ok and env and env.error then env.error("MooseBridge AUFTRAG status event failed: " .. bridge_safe_tostring(err)) end +end + +function MOOSE_BRIDGE:_SendAuftragEvaluatedEvent(auftrag, inputs, From, Event, To, Summary) + if type(auftrag) ~= "table" then return end + local summary = bridge_auftrag_summary(Summary) + local object_id = self:_AuftragObjectId(auftrag) + local ok, err = pcall(function() + self:SendEvent("auftrag.evaluated", { + auftrag_id=object_id, + auftragsnummer=self:_AuftragNumber(auftrag), + auftrag_type=self:_SafeCall(auftrag, "GetType") or auftrag.type, + status=self:_SafeCall(auftrag, "GetState") or self:_SafeCall(auftrag, "GetStatus") or To, + fsm_event="Evaluated", + from=From, + fsm_event_name=Event, + to=To, + commander_id=inputs and inputs.commander_id or nil, + legion_id=inputs and inputs.legion_id or nil, + opsgroup_id=inputs and inputs.opsgroup_id or nil, + cohort_id=inputs and inputs.cohort_id or nil, + target=inputs and (inputs.target_id or inputs.zone_id) or nil, + zones=inputs and inputs.zones or nil, + summary=summary, + auftrag={ + object_id=object_id, + auftragsnummer=self:_AuftragNumber(auftrag), + type=self:_SafeCall(auftrag, "GetType") or auftrag.type, + status=self:_SafeCall(auftrag, "GetState") or self:_SafeCall(auftrag, "GetStatus") or To, + zones=inputs and inputs.zones or nil, + summary_available=summary ~= nil, + summary=summary, + }, + }) + end) + if not ok and env and env.error then env.error("MooseBridge OnAfterEvaluated send failed: " .. bridge_safe_tostring(err)) end +end + +function MOOSE_BRIDGE:_RegisterAuftragEvents(auftrag, inputs) + if type(auftrag) ~= "table" then return end + if auftrag.MooseBridgeEvaluatedEventRegistered then return end + + local bridge = self + auftrag.MooseBridgeEvaluatedEventRegistered = true + local sent_status_events = {} + + local function register_after_event(method_event, output_event) + output_event = output_event or method_event + local method_name = "OnAfter" .. method_event + local previous_handler = auftrag[method_name] + auftrag[method_name] = function(auftrag_object, From, Event, To, ...) + local extra = {...} + if type(previous_handler) == "function" then + pcall(function() previous_handler(auftrag_object, From, Event, To, bridge_unpack(extra)) end) + end + + if output_event == "Evaluated" then + bridge:_SendAuftragEvaluatedEvent(auftrag_object, inputs, From, Event, To, extra[1]) + return + end + + if sent_status_events[output_event] then return end + sent_status_events[output_event] = true + bridge:_SendAuftragStatusEvent(auftrag_object, inputs, output_event, From, Event, To) + end + end + + register_after_event("Queued") + register_after_event("Requested") + register_after_event("Scheduled") + register_after_event("Started") + register_after_event("Executing") + register_after_event("Done") + register_after_event("Cancel") + register_after_event("Evaluated") +end + +function MOOSE_BRIDGE:_AddAuftragToTarget(auftrag, inputs) + if inputs.opsgroup then return self:_AddAuftragToOpsGroup(auftrag, inputs) end + if inputs.commander then return self:_AddAuftragToCommander(auftrag, inputs) end + return self:_AddAuftragToLegion(auftrag, inputs) +end + +function MOOSE_BRIDGE:_BuildAuftragCommandResult(action, auftrag, inputs) + return { + action=action, + commander_id=inputs.commander_id, + legion_id=inputs.legion_id, + opsgroup_id=inputs.opsgroup_id, + cohort_id=inputs.cohort_id, + allowed_legion_ids=inputs.allowed_legion_ids, + allowed_cohort_ids=inputs.allowed_cohort_ids, + clock_start=inputs.clock_start, + clock_stop=inputs.clock_stop, + duration=inputs.duration, + required_assets_min=inputs.required_assets_min, + required_assets_max=inputs.required_assets_max, + weapon_type=inputs.weapon_type, + target=inputs.target_id, + opszone=inputs.opszone_id, + capture_coalition=inputs.capture_coalition, + stay_in_zone_time_s=inputs.stay_in_zone_time_s, + zone=inputs.zone_id, + zones=inputs.zones, + coordinate=inputs.coordinate_id, + dropoff=inputs.dropoff_id, + pickup=inputs.pickup_id, + x=inputs.x, + y=inputs.y, + z=inputs.z, + dropoff_x=inputs.dropoff_x, + dropoff_y=inputs.dropoff_y, + dropoff_z=inputs.dropoff_z, + pickup_x=inputs.pickup_x, + pickup_y=inputs.pickup_y, + pickup_z=inputs.pickup_z, + altitude_ft=inputs.altitude_ft, + engage_weapon_type=inputs.engage_weapon_type, + divebomb=inputs.divebomb, + nshots=inputs.nshots, + radius_m=inputs.radius_m, + carpet_length_m=inputs.carpet_length_m, + length_m=inputs.length_m, + speed_kts=inputs.speed_kts, + ad_infinitum=inputs.ad_infinitum, + randomly=inputs.randomly, + formation=inputs.formation, + depth_m=inputs.depth_m, + heading_deg=inputs.heading_deg, + leg_nm=inputs.leg_nm, + refuel_system=inputs.refuel_system, + range_max_nm=inputs.range_max_nm, + no_engage_zones=inputs.no_engage_zones, + frequency_mhz=inputs.frequency_mhz, + modulation=inputs.modulation, + designation=inputs.designation, + data_link=inputs.data_link, + target_types=inputs.target_types, + orbit_distance_nm=inputs.orbit_distance_nm, + engage_max_distance_nm=inputs.engage_max_distance_nm, + offset_x=inputs.offset_x, + offset_y=inputs.offset_y, + offset_z=inputs.offset_z, + transport_groups=inputs.transport_groups, + pickup_radius_m=inputs.pickup_radius_m, + selected_payload_uid=inputs.selected_payload_uid, + auftrag_id=self:_AuftragObjectId(auftrag), + auftragsnummer=self:_AuftragNumber(auftrag), + auftrag_type=self:_SafeCall(auftrag, "GetType") or auftrag.type, + added=true, + } +end + +function MOOSE_BRIDGE:_CreateZonePatrolAuftrag(cmd, action, constructor_name) + local inputs = self:_CommonAuftragCommandInputs(cmd) + local zone, zone_err = self:_ResolveZonePatrolZone(inputs) + if not zone then error(zone_err) end + + local coordinate, coordinate_err = self:_ResolveZonePatrolCoordinate(inputs) + if coordinate_err then error(coordinate_err) end + + if not AUFTRAG or type(AUFTRAG[constructor_name]) ~= "function" then error("AUFTRAG:" .. constructor_name .. " is not available") end + + local altitude_ft = inputs.altitude_ft and tonumber(inputs.altitude_ft) or nil + local speed_kts = inputs.speed_kts and tonumber(inputs.speed_kts) or nil + local heading_deg = inputs.heading_deg and tonumber(inputs.heading_deg) or nil + local leg_nm = inputs.leg_nm and tonumber(inputs.leg_nm) or nil + local target_types = self:_NormalizeTargetTypes(inputs.target_types) + inputs.target_types = target_types + + local auftrag = AUFTRAG[constructor_name](AUFTRAG, zone, altitude_ft, speed_kts, coordinate, heading_deg, leg_nm, target_types) + + self:_AddAuftragToTarget(auftrag, inputs) + return self:_BuildAuftragCommandResult(action, auftrag, inputs) +end + +function MOOSE_BRIDGE:_CreateOrbitRoleAuftrag(cmd, action, constructor_name, include_refuel_system) + local inputs = self:_CommonAuftragCommandInputs(cmd) + local target, target_err = self:_ResolveOrbitTarget(inputs) + if not target then error(target_err) end + + if not AUFTRAG or type(AUFTRAG[constructor_name]) ~= "function" then error("AUFTRAG:" .. constructor_name .. " is not available") end + + local altitude_ft = inputs.altitude_ft and tonumber(inputs.altitude_ft) or nil + local speed_kts = inputs.speed_kts and tonumber(inputs.speed_kts) or nil + local heading_deg = inputs.heading_deg and tonumber(inputs.heading_deg) or nil + local leg_nm = inputs.leg_nm and tonumber(inputs.leg_nm) or nil + local refuel_system = include_refuel_system and inputs.refuel_system and tonumber(inputs.refuel_system) or nil + local auftrag = nil + + if include_refuel_system then + auftrag = AUFTRAG[constructor_name](AUFTRAG, target, altitude_ft, speed_kts, heading_deg, leg_nm, refuel_system) + else + auftrag = AUFTRAG[constructor_name](AUFTRAG, target, altitude_ft, speed_kts, heading_deg, leg_nm) + end + + self:_AddAuftragToTarget(auftrag, inputs) + return self:_BuildAuftragCommandResult(action, auftrag, inputs) +end + +function MOOSE_BRIDGE:_CreateCasEnhancedAuftrag(cmd) + local inputs = self:_CommonAuftragCommandInputs(cmd) + local zone, zone_err = self:_ResolveZonePatrolZone(inputs) + if not zone then error(zone_err) end + + if not AUFTRAG or not AUFTRAG.NewCASENHANCED then error("AUFTRAG:NewCASENHANCED is not available") end + + local altitude_ft = inputs.altitude_ft and tonumber(inputs.altitude_ft) or nil + local speed_kts = inputs.speed_kts and tonumber(inputs.speed_kts) or nil + local range_max_nm = inputs.range_max_nm and tonumber(inputs.range_max_nm) or nil + local no_engage_zone_set = self:_BuildNoEngageZoneSet(inputs.no_engage_zones) + local target_types = self:_NormalizeTargetTypes(inputs.target_types) + inputs.no_engage_zones = self:_NormalizeStringList(inputs.no_engage_zones) + inputs.target_types = target_types + + local auftrag = AUFTRAG:NewCASENHANCED(zone, altitude_ft, speed_kts, range_max_nm, no_engage_zone_set, target_types) + + self:_AddAuftragToTarget(auftrag, inputs) + return self:_BuildAuftragCommandResult("auftrag.create_casenhanced", auftrag, inputs) +end + +function MOOSE_BRIDGE:_CreateFacAuftrag(cmd) + local inputs = self:_CommonAuftragCommandInputs(cmd) + local zone, zone_err = self:_ResolveZonePatrolZone(inputs) + if not zone then error(zone_err) end + + if not AUFTRAG or not AUFTRAG.NewFAC then error("AUFTRAG:NewFAC is not available") end + + local speed_kts = inputs.speed_kts and tonumber(inputs.speed_kts) or nil + local altitude_ft = inputs.altitude_ft and tonumber(inputs.altitude_ft) or nil + local frequency_mhz = inputs.frequency_mhz and tonumber(inputs.frequency_mhz) or nil + local modulation = inputs.modulation and tonumber(inputs.modulation) or nil + + local auftrag = AUFTRAG:NewFAC(zone, speed_kts, altitude_ft, frequency_mhz, modulation) + + self:_AddAuftragToTarget(auftrag, inputs) + return self:_BuildAuftragCommandResult("auftrag.create_fac", auftrag, inputs) +end + +function MOOSE_BRIDGE:_CreatePatrolZoneAuftrag(cmd) + local inputs = self:_CommonAuftragCommandInputs(cmd) + local zone, zone_err = self:_ResolveZonePatrolZone(inputs) + if not zone then error(zone_err) end + + if not AUFTRAG or not AUFTRAG.NewPATROLZONE then error("AUFTRAG:NewPATROLZONE is not available") end + + local speed_kts = inputs.speed_kts and tonumber(inputs.speed_kts) or nil + local altitude_ft = inputs.altitude_ft and tonumber(inputs.altitude_ft) or nil + local auftrag = AUFTRAG:NewPATROLZONE(zone, speed_kts, altitude_ft, inputs.formation) + + self:_AddAuftragToTarget(auftrag, inputs) + return self:_BuildAuftragCommandResult("auftrag.create_patrolzone", auftrag, inputs) +end + +function MOOSE_BRIDGE:_CreateReconAuftrag(cmd) + local inputs = self:_CommonAuftragCommandInputs(cmd) + local zone_set, zone_ids = self:_BuildZoneSet(inputs.zones, "RECON", true) + inputs.zones = zone_ids + + if not AUFTRAG or not AUFTRAG.NewRECON then error("AUFTRAG:NewRECON is not available") end + local speed_kts = inputs.speed_kts and tonumber(inputs.speed_kts) or nil + local altitude_ft = inputs.altitude_ft and tonumber(inputs.altitude_ft) or nil + local auftrag = AUFTRAG:NewRECON( + zone_set, + speed_kts, + altitude_ft, + inputs.ad_infinitum, + inputs.randomly, + inputs.formation + ) + + self:_AddAuftragToTarget(auftrag, inputs) + return self:_BuildAuftragCommandResult("auftrag.create_recon", auftrag, inputs) +end + +function MOOSE_BRIDGE:_CreateCaptureZoneAuftrag(cmd) + local inputs = self:_CommonAuftragCommandInputs(cmd) + local opszone, opszone_err = self:_ResolveCaptureOpsZone(inputs) + if not opszone then error(opszone_err) end + if inputs.capture_coalition == nil then error("CAPTUREZONE requires capture_coalition") end + + if not AUFTRAG or not AUFTRAG.NewCAPTUREZONE then error("AUFTRAG:NewCAPTUREZONE is not available") end + + local speed_kts = inputs.speed_kts and tonumber(inputs.speed_kts) or nil + local altitude_ft = inputs.altitude_ft and tonumber(inputs.altitude_ft) or nil + local stay_in_zone_time_s = inputs.stay_in_zone_time_s and tonumber(inputs.stay_in_zone_time_s) or nil + local auftrag = AUFTRAG:NewCAPTUREZONE(opszone, inputs.capture_coalition, speed_kts, altitude_ft, inputs.formation, stay_in_zone_time_s) + + self:_AddAuftragToTarget(auftrag, inputs) + return self:_BuildAuftragCommandResult("auftrag.create_capturezone", auftrag, inputs) +end + +function MOOSE_BRIDGE:_CreateZoneOnlyAuftrag(cmd, action, constructor_name) + local inputs = self:_CommonAuftragCommandInputs(cmd) + local zone, zone_err = self:_ResolveZonePatrolZone(inputs) + if not zone then error(zone_err) end + + if not AUFTRAG or type(AUFTRAG[constructor_name]) ~= "function" then error("AUFTRAG:" .. constructor_name .. " is not available") end + + local auftrag = AUFTRAG[constructor_name](AUFTRAG, zone) + + self:_AddAuftragToTarget(auftrag, inputs) + return self:_BuildAuftragCommandResult(action, auftrag, inputs) +end + +function MOOSE_BRIDGE:_CreateFacaAuftrag(cmd) + local inputs = self:_CommonAuftragCommandInputs(cmd) + local target, target_err = self:_ResolveGroupAuftragTarget(inputs) + if not target then error(target_err) end + + if not AUFTRAG or not AUFTRAG.NewFACA then error("AUFTRAG:NewFACA is not available") end + + local data_link = bridge_bool_param(inputs.data_link) + local frequency_mhz = inputs.frequency_mhz and tonumber(inputs.frequency_mhz) or nil + local modulation = inputs.modulation and tonumber(inputs.modulation) or nil + + local auftrag = AUFTRAG:NewFACA(target, inputs.designation, data_link, frequency_mhz, modulation) + + self:_AddAuftragToTarget(auftrag, inputs) + return self:_BuildAuftragCommandResult("auftrag.create_faca", auftrag, inputs) +end + +function MOOSE_BRIDGE:_CreateSeadAuftrag(cmd) + local inputs = self:_CommonAuftragCommandInputs(cmd) + local target, target_err = self:_ResolveGroupOrUnitAuftragTarget(inputs) + if not target then error(target_err) end + + if not AUFTRAG or not AUFTRAG.NewSEAD then error("AUFTRAG:NewSEAD is not available") end + + local altitude_ft = inputs.altitude_ft and tonumber(inputs.altitude_ft) or nil + local auftrag = AUFTRAG:NewSEAD(target, altitude_ft) + + self:_AddAuftragToTarget(auftrag, inputs) + return self:_BuildAuftragCommandResult("auftrag.create_sead", auftrag, inputs) +end + +function MOOSE_BRIDGE:_CreateAntiShipAuftrag(cmd) + local inputs = self:_CommonAuftragCommandInputs(cmd) + local target, target_err = self:_ResolveGroupOrUnitAuftragTarget(inputs) + if not target then error(target_err) end + + if not AUFTRAG or not AUFTRAG.NewANTISHIP then error("AUFTRAG:NewANTISHIP is not available") end + + local altitude_ft = inputs.altitude_ft and tonumber(inputs.altitude_ft) or nil + local auftrag = AUFTRAG:NewANTISHIP(target, altitude_ft) + + self:_AddAuftragToTarget(auftrag, inputs) + return self:_BuildAuftragCommandResult("auftrag.create_antiship", auftrag, inputs) +end + +function MOOSE_BRIDGE:_CreateOnGuardAuftrag(cmd) + local inputs = self:_CommonAuftragCommandInputs(cmd) + local target, target_err = self:_ResolveOnGuardTarget(inputs) + if not target then error(target_err) end + + if not AUFTRAG or not AUFTRAG.NewONGUARD then error("AUFTRAG:NewONGUARD is not available") end + + local auftrag = AUFTRAG:NewONGUARD(target) + + self:_AddAuftragToTarget(auftrag, inputs) + return self:_BuildAuftragCommandResult("auftrag.create_onguard", auftrag, inputs) +end + +function MOOSE_BRIDGE:_CreateInterceptAuftrag(cmd) + local inputs = self:_CommonAuftragCommandInputs(cmd) + local target, target_err = self:_ResolveGroupOrUnitAuftragTarget(inputs) + if not target then error(target_err) end + + if not AUFTRAG or not AUFTRAG.NewINTERCEPT then error("AUFTRAG:NewINTERCEPT is not available") end + + local auftrag = AUFTRAG:NewINTERCEPT(target) + + self:_AddAuftragToTarget(auftrag, inputs) + return self:_BuildAuftragCommandResult("auftrag.create_intercept", auftrag, inputs) +end + +function MOOSE_BRIDGE:_CreateStrikeAuftrag(cmd) + local inputs = self:_CommonAuftragCommandInputs(cmd) + local target, target_err = self:_ResolveStrikeTarget(inputs) + if not target then error(target_err) end + + if not AUFTRAG or not AUFTRAG.NewSTRIKE then error("AUFTRAG:NewSTRIKE is not available") end + + local altitude_ft = inputs.altitude_ft and tonumber(inputs.altitude_ft) or nil + local engage_weapon_type = inputs.engage_weapon_type and tonumber(inputs.engage_weapon_type) or nil + local auftrag = AUFTRAG:NewSTRIKE(target, altitude_ft, engage_weapon_type) + + self:_AddAuftragToTarget(auftrag, inputs) + return self:_BuildAuftragCommandResult("auftrag.create_strike", auftrag, inputs) +end + +function MOOSE_BRIDGE:_CreateStrafingAuftrag(cmd) + local inputs = self:_CommonAuftragCommandInputs(cmd) + local target, target_err = self:_ResolveStrafingTarget(inputs) + if not target then error(target_err) end + + if not AUFTRAG or not AUFTRAG.NewSTRAFING then error("AUFTRAG:NewSTRAFING is not available") end + + local altitude_ft = inputs.altitude_ft and tonumber(inputs.altitude_ft) or nil + local length_m = inputs.length_m and tonumber(inputs.length_m) or nil + local auftrag = AUFTRAG:NewSTRAFING(target, altitude_ft, length_m) + + self:_AddAuftragToTarget(auftrag, inputs) + return self:_BuildAuftragCommandResult("auftrag.create_strafing", auftrag, inputs) +end + +function MOOSE_BRIDGE:_CreateBombRunwayAuftrag(cmd) + local inputs = self:_CommonAuftragCommandInputs(cmd) + local target, target_err = self:_ResolveAirbaseAuftragTarget(inputs) + if not target then error(target_err) end + + if not AUFTRAG or not AUFTRAG.NewBOMBRUNWAY then error("AUFTRAG:NewBOMBRUNWAY is not available") end + + local altitude_ft = inputs.altitude_ft and tonumber(inputs.altitude_ft) or nil + local auftrag = AUFTRAG:NewBOMBRUNWAY(target, altitude_ft) + + self:_AddAuftragToTarget(auftrag, inputs) + return self:_BuildAuftragCommandResult("auftrag.create_bombrunway", auftrag, inputs) +end + +function MOOSE_BRIDGE:_CreateBombCarpetAuftrag(cmd) + local inputs = self:_CommonAuftragCommandInputs(cmd) + local target, target_err = self:_ResolveBombCarpetTarget(inputs) + if not target then error(target_err) end + + if not AUFTRAG or not AUFTRAG.NewBOMBCARPET then error("AUFTRAG:NewBOMBCARPET is not available") end + + local altitude_ft = inputs.altitude_ft and tonumber(inputs.altitude_ft) or nil + local carpet_length_m = inputs.carpet_length_m and tonumber(inputs.carpet_length_m) or nil + local auftrag = AUFTRAG:NewBOMBCARPET(target, altitude_ft, carpet_length_m) + + self:_AddAuftragToTarget(auftrag, inputs) + return self:_BuildAuftragCommandResult("auftrag.create_bombcarpet", auftrag, inputs) +end + +function MOOSE_BRIDGE:_CreateGroundEscortAuftrag(cmd) + local inputs = self:_CommonAuftragCommandInputs(cmd) + local target, target_err = self:_ResolveGroupAuftragTarget(inputs) + if not target then error(target_err) end + + if not AUFTRAG or not AUFTRAG.NewGROUNDESCORT then error("AUFTRAG:NewGROUNDESCORT is not available") end + + local orbit_distance_nm = inputs.orbit_distance_nm and tonumber(inputs.orbit_distance_nm) or nil + local target_types = self:_NormalizeTargetTypes(inputs.target_types) + inputs.target_types = target_types + local auftrag = AUFTRAG:NewGROUNDESCORT(target, orbit_distance_nm, target_types) + + self:_AddAuftragToTarget(auftrag, inputs) + return self:_BuildAuftragCommandResult("auftrag.create_groundescort", auftrag, inputs) +end + +function MOOSE_BRIDGE:_CreateGroundAttackAuftrag(cmd) + local inputs = self:_CommonAuftragCommandInputs(cmd) + local target, target_err = self:_ResolvePositionableAttackAuftragTarget(inputs) + if not target then error(target_err) end + + if not AUFTRAG or not AUFTRAG.NewGROUNDATTACK then error("AUFTRAG:NewGROUNDATTACK is not available") end + + local speed_kts = inputs.speed_kts and tonumber(inputs.speed_kts) or nil + local auftrag = AUFTRAG:NewGROUNDATTACK(target, speed_kts, inputs.formation) + + self:_AddAuftragToTarget(auftrag, inputs) + return self:_BuildAuftragCommandResult("auftrag.create_groundattack", auftrag, inputs) +end + +function MOOSE_BRIDGE:_CreateNavalEngagementAuftrag(cmd) + local inputs = self:_CommonAuftragCommandInputs(cmd) + local target, target_err = self:_ResolvePositionableAttackAuftragTarget(inputs) + if not target then error(target_err) end + + if not AUFTRAG or not AUFTRAG.NewNAVALENGAGEMENT then error("AUFTRAG:NewNAVALENGAGEMENT is not available") end + + local speed_kts = inputs.speed_kts and tonumber(inputs.speed_kts) or nil + local depth_m = inputs.depth_m and tonumber(inputs.depth_m) or nil + local auftrag = AUFTRAG:NewNAVALENGAGEMENT(target, speed_kts, depth_m) + + self:_AddAuftragToTarget(auftrag, inputs) + return self:_BuildAuftragCommandResult("auftrag.create_navalengagement", auftrag, inputs) +end + +function MOOSE_BRIDGE:_CreateEscortAuftrag(cmd) + local inputs = self:_CommonAuftragCommandInputs(cmd) + local target, target_err = self:_ResolveGroupAuftragTarget(inputs) + if not target then error(target_err) end + + if not AUFTRAG or not AUFTRAG.NewESCORT then error("AUFTRAG:NewESCORT is not available") end + + local offset_vector = self:_BuildOffsetVector(inputs) + local engage_max_distance_nm = inputs.engage_max_distance_nm and tonumber(inputs.engage_max_distance_nm) or nil + local target_types = self:_NormalizeTargetTypes(inputs.target_types) + inputs.target_types = target_types + local auftrag = AUFTRAG:NewESCORT(target, offset_vector, engage_max_distance_nm, target_types) + + self:_AddAuftragToTarget(auftrag, inputs) + return self:_BuildAuftragCommandResult("auftrag.create_escort", auftrag, inputs) +end + +function MOOSE_BRIDGE:_CreateRescueHeloAuftrag(cmd) + local inputs = self:_CommonAuftragCommandInputs(cmd) + local target, target_err = self:_ResolveUnitAuftragTarget(inputs) + if not target then error(target_err) end + + if not AUFTRAG or not AUFTRAG.NewRESCUEHELO then error("AUFTRAG:NewRESCUEHELO is not available") end + + local auftrag = AUFTRAG:NewRESCUEHELO(target) + + self:_AddAuftragToTarget(auftrag, inputs) + return self:_BuildAuftragCommandResult("auftrag.create_rescuehelo", auftrag, inputs) +end + +function MOOSE_BRIDGE:_CreateTroopTransportAuftrag(cmd) + local inputs = self:_CommonAuftragCommandInputs(cmd) + local transport_group_set, group_ids = self:_BuildGroupSet(inputs.transport_groups) + inputs.transport_groups = group_ids + + local dropoff_coordinate, dropoff_err = self:_ResolveCoordinateFromNamedFields(inputs.dropoff_id, inputs.dropoff_x, inputs.dropoff_y, inputs.dropoff_z, "Dropoff", true) + if not dropoff_coordinate then error(dropoff_err) end + + local pickup_coordinate, pickup_err = self:_ResolveCoordinateFromNamedFields(inputs.pickup_id, inputs.pickup_x, inputs.pickup_y, inputs.pickup_z, "Pickup", false) + if pickup_err then error(pickup_err) end + + if not AUFTRAG or not AUFTRAG.NewTROOPTRANSPORT then error("AUFTRAG:NewTROOPTRANSPORT is not available") end + + local pickup_radius_m = inputs.pickup_radius_m and tonumber(inputs.pickup_radius_m) or nil + local auftrag = AUFTRAG:NewTROOPTRANSPORT(transport_group_set, dropoff_coordinate, pickup_coordinate, pickup_radius_m) + + self:_AddAuftragToTarget(auftrag, inputs) + return self:_BuildAuftragCommandResult("auftrag.create_trooptransport", auftrag, inputs) +end + +function MOOSE_BRIDGE:RegisterAuftragExecutionCommands() + self:RegisterCommand("auftrag.create_bai", function(cmd) + local inputs = self:_CommonAuftragCommandInputs(cmd) + local target, target_err = self:_ResolveObjectAuftragTarget(inputs) + if not target then error(target_err) end + + if not AUFTRAG or not AUFTRAG.NewBAI then error("AUFTRAG:NewBAI is not available") end + + local auftrag = nil + if inputs.altitude_ft ~= nil then + auftrag = AUFTRAG:NewBAI(target, tonumber(inputs.altitude_ft)) + else + auftrag = AUFTRAG:NewBAI(target) + end + + self:_AddAuftragToTarget(auftrag, inputs) + return self:_BuildAuftragCommandResult("auftrag.create_bai", auftrag, inputs) + end) + + self:RegisterCommand("auftrag.create_bombing", function(cmd) + local inputs = self:_CommonAuftragCommandInputs(cmd) + local target, target_err = self:_ResolveBombingTarget(inputs) + if not target then error(target_err) end + + if not AUFTRAG or not AUFTRAG.NewBOMBING then error("AUFTRAG:NewBOMBING is not available") end + + local altitude_ft = inputs.altitude_ft and tonumber(inputs.altitude_ft) or nil + local engage_weapon_type = inputs.engage_weapon_type and tonumber(inputs.engage_weapon_type) or nil + local divebomb = bridge_bool_param(inputs.divebomb) + + local auftrag = AUFTRAG:NewBOMBING(target, altitude_ft, engage_weapon_type, divebomb) + + self:_AddAuftragToTarget(auftrag, inputs) + return self:_BuildAuftragCommandResult("auftrag.create_bombing", auftrag, inputs) + end) + + self:RegisterCommand("auftrag.create_arty", function(cmd) + local inputs = self:_CommonAuftragCommandInputs(cmd) + local target, target_err = self:_ResolveArtyTarget(inputs) + if not target then error(target_err) end + + if not AUFTRAG or not AUFTRAG.NewARTY then error("AUFTRAG:NewARTY is not available") end + + local nshots = inputs.nshots and tonumber(inputs.nshots) or nil + local radius_m = inputs.radius_m and tonumber(inputs.radius_m) or nil + local auftrag = AUFTRAG:NewARTY(target, nshots, radius_m) + + self:_AddAuftragToTarget(auftrag, inputs) + return self:_BuildAuftragCommandResult("auftrag.create_arty", auftrag, inputs) + end) + + self:RegisterCommand("auftrag.create_orbit", function(cmd) + local inputs = self:_CommonAuftragCommandInputs(cmd) + local target, target_err = self:_ResolveOrbitTarget(inputs) + if not target then error(target_err) end + + if not AUFTRAG or not AUFTRAG.NewORBIT then error("AUFTRAG:NewORBIT is not available") end + + local altitude_ft = inputs.altitude_ft and tonumber(inputs.altitude_ft) or nil + local speed_kts = inputs.speed_kts and tonumber(inputs.speed_kts) or nil + local heading_deg = inputs.heading_deg and tonumber(inputs.heading_deg) or nil + local leg_nm = inputs.leg_nm and tonumber(inputs.leg_nm) or nil + local auftrag = AUFTRAG:NewORBIT(target, altitude_ft, speed_kts, heading_deg, leg_nm) + + self:_AddAuftragToTarget(auftrag, inputs) + return self:_BuildAuftragCommandResult("auftrag.create_orbit", auftrag, inputs) + end) + + self:RegisterCommand("auftrag.create_awacs", function(cmd) + return self:_CreateOrbitRoleAuftrag(cmd, "auftrag.create_awacs", "NewAWACS", false) + end) + + self:RegisterCommand("auftrag.create_tanker", function(cmd) + return self:_CreateOrbitRoleAuftrag(cmd, "auftrag.create_tanker", "NewTANKER", true) + end) + + self:RegisterCommand("auftrag.create_cap", function(cmd) + return self:_CreateZonePatrolAuftrag(cmd, "auftrag.create_cap", "NewCAP") + end) + + self:RegisterCommand("auftrag.create_cas", function(cmd) + return self:_CreateZonePatrolAuftrag(cmd, "auftrag.create_cas", "NewCAS") + end) + + self:RegisterCommand("auftrag.create_casenhanced", function(cmd) + return self:_CreateCasEnhancedAuftrag(cmd) + end) + + self:RegisterCommand("auftrag.create_fac", function(cmd) + return self:_CreateFacAuftrag(cmd) + end) + + self:RegisterCommand("auftrag.create_patrolzone", function(cmd) + return self:_CreatePatrolZoneAuftrag(cmd) + end) + + self:RegisterCommand("auftrag.create_recon", function(cmd) + return self:_CreateReconAuftrag(cmd) + end) + + self:RegisterCommand("auftrag.create_capturezone", function(cmd) + return self:_CreateCaptureZoneAuftrag(cmd) + end) + + self:RegisterCommand("auftrag.create_faca", function(cmd) + return self:_CreateFacaAuftrag(cmd) + end) + + self:RegisterCommand("auftrag.create_ammosupply", function(cmd) + return self:_CreateZoneOnlyAuftrag(cmd, "auftrag.create_ammosupply", "NewAMMOSUPPLY") + end) + + self:RegisterCommand("auftrag.create_fuelsupply", function(cmd) + return self:_CreateZoneOnlyAuftrag(cmd, "auftrag.create_fuelsupply", "NewFUELSUPPLY") + end) + + self:RegisterCommand("auftrag.create_rearming", function(cmd) + return self:_CreateZoneOnlyAuftrag(cmd, "auftrag.create_rearming", "NewREARMING") + end) + + self:RegisterCommand("auftrag.create_airdefense", function(cmd) + return self:_CreateZoneOnlyAuftrag(cmd, "auftrag.create_airdefense", "NewAIRDEFENSE") + end) + + self:RegisterCommand("auftrag.create_ewr", function(cmd) + return self:_CreateZoneOnlyAuftrag(cmd, "auftrag.create_ewr", "NewEWR") + end) + + self:RegisterCommand("auftrag.create_nothing", function(cmd) + return self:_CreateZoneOnlyAuftrag(cmd, "auftrag.create_nothing", "NewNOTHING") + end) + + self:RegisterCommand("auftrag.create_sead", function(cmd) + return self:_CreateSeadAuftrag(cmd) + end) + + self:RegisterCommand("auftrag.create_antiship", function(cmd) + return self:_CreateAntiShipAuftrag(cmd) + end) + + self:RegisterCommand("auftrag.create_onguard", function(cmd) + return self:_CreateOnGuardAuftrag(cmd) + end) + + self:RegisterCommand("auftrag.create_intercept", function(cmd) + return self:_CreateInterceptAuftrag(cmd) + end) + + self:RegisterCommand("auftrag.create_strike", function(cmd) + return self:_CreateStrikeAuftrag(cmd) + end) + + self:RegisterCommand("auftrag.create_strafing", function(cmd) + return self:_CreateStrafingAuftrag(cmd) + end) + + self:RegisterCommand("auftrag.create_bombrunway", function(cmd) + return self:_CreateBombRunwayAuftrag(cmd) + end) + + self:RegisterCommand("auftrag.create_bombcarpet", function(cmd) + return self:_CreateBombCarpetAuftrag(cmd) + end) + + self:RegisterCommand("auftrag.create_groundescort", function(cmd) + return self:_CreateGroundEscortAuftrag(cmd) + end) + + self:RegisterCommand("auftrag.create_groundattack", function(cmd) + return self:_CreateGroundAttackAuftrag(cmd) + end) + + self:RegisterCommand("auftrag.create_navalengagement", function(cmd) + return self:_CreateNavalEngagementAuftrag(cmd) + end) + + self:RegisterCommand("auftrag.create_escort", function(cmd) + return self:_CreateEscortAuftrag(cmd) + end) + + self:RegisterCommand("auftrag.create_rescuehelo", function(cmd) + return self:_CreateRescueHeloAuftrag(cmd) + end) + + self:RegisterCommand("auftrag.create_trooptransport", function(cmd) + return self:_CreateTroopTransportAuftrag(cmd) + end) + + self:RegisterCommand("auftrag.cancel", function(cmd) + return self:_CallAuftragLifecycleMethod(cmd, "auftrag.cancel", "Cancel") + end) + + self:RegisterCommand("auftrag.pause", function(cmd) + return self:_CallAuftragLifecycleMethod(cmd, "auftrag.pause", "Pause") + end) + + self:RegisterCommand("auftrag.resume", function(cmd) + return self:_CallAuftragLifecycleMethod(cmd, "auftrag.resume", "Resume") + end) + + self:RegisterCommand("auftrag.assign", function(cmd) + return self:_AssignTrackedAuftrag(cmd) + end) + + self:RegisterCommand("cohort.set_weapon_range", function(cmd) + return self:_SetCohortWeaponRange(cmd) + end) +end + +local _moose_bridge_base_add_auftrag_candidate = MOOSE_BRIDGE._AddAuftragCandidate + +function MOOSE_BRIDGE:_AddAuftragCandidate(result, seen, auftrag, source) + self:_TrackAuftragReference(auftrag) + return _moose_bridge_base_add_auftrag_candidate(self, result, seen, auftrag, source) +end + +local _moose_bridge_base_build_cohort_snapshot_item = MOOSE_BRIDGE._BuildCohortSnapshotItem + +function MOOSE_BRIDGE:_BuildCohortSnapshotItem(cohort_name, cohort, source) + local item = _moose_bridge_base_build_cohort_snapshot_item(self, cohort_name, cohort, source) + if type(item) ~= "table" or type(cohort) ~= "table" then return item end + + local mission_range = self:_SafeCall(cohort, "GetMissionRange") or cohort.missionRange or cohort.MissionRange + item.mission_range_m = mission_range + + return item +end + +local _moose_bridge_base_build_auftrag_snapshot_item = MOOSE_BRIDGE._BuildAuftragSnapshotItem + +function MOOSE_BRIDGE:_BuildAuftragSnapshotItem(auftrag, source) + local item = _moose_bridge_base_build_auftrag_snapshot_item(self, auftrag, source) + if type(item) ~= "table" or type(auftrag) ~= "table" then return item end + + local summary = bridge_auftrag_summary(auftrag.summary) + item.d_tevaluate = auftrag.dTevaluate + item.ready_to_evaluate = bridge_auftrag_ready_to_evaluate(auftrag) + item.summary_available = summary ~= nil + item.summary = summary + + return item +end + +function MOOSE_BRIDGE:_CollectAuftragCandidatesFromLegion(result, seen, legion) + if type(legion) ~= "table" then return end + local queues = { + legion.missionqueue, + legion.missions, + legion.auftraege, + legion.missionQueue, + } + for _, queue in ipairs(queues) do + if type(queue) == "table" then + for _, auftrag in pairs(queue) do self:_AddAuftragCandidate(result, seen, auftrag, "legion.missionqueue") end + end + end +end + +function MOOSE_BRIDGE:_CollectAuftragCandidatesFromTracked(result, seen) + if type(self.TrackedAuftraege) ~= "table" then return end + for object_id, auftrag in pairs(self.TrackedAuftraege) do + if type(auftrag) == "table" then + self:_AddAuftragCandidate(result, seen, auftrag, "bridge.tracked") + else + self.TrackedAuftraege[object_id] = nil + end + end +end + +function MOOSE_BRIDGE:_CollectAuftragCandidatesFromCommanders(result, seen) + for _, commander in pairs(self.RegisteredCommanders or {}) do + if type(commander) == "table" and type(commander.missionqueue) == "table" then + for _, auftrag in pairs(commander.missionqueue) do + self:_AddAuftragCandidate(result, seen, auftrag, "commander.missionqueue") + end + end + end +end + +local _moose_bridge_base_build_auftrag_snapshot = MOOSE_BRIDGE.BuildAuftragSnapshot + +function MOOSE_BRIDGE:BuildAuftragSnapshot() + local result = _moose_bridge_base_build_auftrag_snapshot(self) or {} + local seen = {} + for _, item in ipairs(result) do + if item.object_id then seen[item.object_id] = true end + end + + if _DATABASE and type(_DATABASE.LEGIONS) == "table" then + for _, legion in pairs(_DATABASE.LEGIONS) do + self:_CollectAuftragCandidatesFromLegion(result, seen, legion) + end + end + + self:_CollectAuftragCandidatesFromCommanders(result, seen) + self:_CollectAuftragCandidatesFromTracked(result, seen) + + return result +end + +local _moose_bridge_base_register_default_commands = MOOSE_BRIDGE.RegisterDefaultCommands + +function MOOSE_BRIDGE:RegisterDefaultCommands() + _moose_bridge_base_register_default_commands(self) + self:RegisterAuftragExecutionCommands() +end diff --git a/Moose Development/Moose/Python/MooseBridgeAuftragTraceExtension.lua b/Moose Development/Moose/Python/MooseBridgeAuftragTraceExtension.lua new file mode 100644 index 000000000..1fe20360f --- /dev/null +++ b/Moose Development/Moose/Python/MooseBridgeAuftragTraceExtension.lua @@ -0,0 +1,240 @@ +-- Optional AUFTRAG tracing and diagnostic extension for MOOSE Bridge. +-- +-- Load after MooseBridge.lua and, when used together with execution commands, +-- after MooseBridgeAuftragExecutionExtension.lua. This file only adds read-only +-- tracing helpers and does not change AUFTRAG execution semantics. + +if not MOOSE_BRIDGE then error("Load MooseBridge.lua before MooseBridgeAuftragTraceExtension.lua") end + +local function trace_safe_tostring(value) + if value == nil then return "nil" end + return tostring(value) +end + +local function trace_split_object_id(object_id) + if type(object_id) ~= "string" then return nil, nil end + local prefix, name = string.match(object_id, "^([^:]+):(.+)$") + if not prefix or not name then return nil, nil end + return string.upper(prefix), name +end + +local function trace_append_unique(result, seen, value) + if value == nil then return end + local text = tostring(value) + if text == "" or seen[text] then return end + result[#result + 1] = text + seen[text] = true +end + +local function trace_bool(value) + if value == nil then return false end + return value and true or false +end + +local function trace_table_count(value) + if type(value) ~= "table" then return 0 end + local count = 0 + for _, _ in pairs(value) do count = count + 1 end + return count +end + +function MOOSE_BRIDGE:_TraceAuftragId(value) + if type(value) ~= "table" then return nil end + return self:_AuftragObjectId(value) +end + +function MOOSE_BRIDGE:_TraceCollectAuftragIds(queue) + local result = {}; local seen = {} + if type(queue) ~= "table" then return result end + for _, auftrag in pairs(queue) do + trace_append_unique(result, seen, self:_TraceAuftragId(auftrag)) + end + return result +end + +function MOOSE_BRIDGE:_TraceQueueContainsAuftrag(queue, auftrag_id) + if type(queue) ~= "table" then return false end + for _, auftrag in pairs(queue) do + if self:_TraceAuftragId(auftrag) == auftrag_id then return true end + end + return false +end + +function MOOSE_BRIDGE:_TraceFindAuftrag(auftrag_id) + if type(auftrag_id) ~= "string" or auftrag_id == "" then return nil, nil end + + if type(self.TrackedAuftraege) == "table" and type(self.TrackedAuftraege[auftrag_id]) == "table" then + return self.TrackedAuftraege[auftrag_id], "bridge.tracked" + end + + if _DATABASE and type(_DATABASE.LEGIONS) == "table" then + for _, legion in pairs(_DATABASE.LEGIONS) do + local queues = {legion.missionqueue, legion.missions, legion.auftraege, legion.missionQueue} + for _, queue in ipairs(queues) do + if type(queue) == "table" then + for _, auftrag in pairs(queue) do + if self:_TraceAuftragId(auftrag) == auftrag_id then return auftrag, "legion.queue" end + end + end + end + end + end + + -- MOOSE stores all OPSGROUP specializations here despite the FLIGHTGROUPS name. + if _DATABASE and type(_DATABASE.FLIGHTGROUPS) == "table" then + for _, opsgroup in pairs(_DATABASE.FLIGHTGROUPS) do + local current = opsgroup.currentmission or opsgroup.missioncurrent or opsgroup.currentMission + if self:_TraceAuftragId(current) == auftrag_id then return current, "opsgroup.current" end + local queues = {opsgroup.missionqueue, opsgroup.missions, opsgroup.auftraege, opsgroup.missionQueue} + for _, queue in ipairs(queues) do + if type(queue) == "table" then + for _, auftrag in pairs(queue) do + if self:_TraceAuftragId(auftrag) == auftrag_id then return auftrag, "opsgroup.queue" end + end + end + end + end + end + + return nil, nil +end + +function MOOSE_BRIDGE:_TraceLegionItem(legion_name, legion, auftrag_id, source) + local item = self:_BuildLegionSnapshotItem(legion_name, legion, source) + if type(item) ~= "table" then return nil end + item.missionqueue_count = trace_table_count(legion and legion.missionqueue) + item.missionqueue_contains_auftrag = self:_TraceQueueContainsAuftrag(legion and legion.missionqueue, auftrag_id) + item.is_running = tostring(item.state or "") == "Running" + return item +end + +function MOOSE_BRIDGE:_TraceCohortItem(cohort_name, cohort, source) + local item = self:_BuildCohortSnapshotItem(cohort_name, cohort, source) + if type(item) ~= "table" then return nil end + item.asset_count = item.asset_count or trace_table_count(cohort and cohort.assets) + item.stock_asset_count = item.stock_asset_count or trace_table_count(cohort and cohort.stock) + item.spawned_asset_count = item.spawned_asset_count or trace_table_count(cohort and cohort.spawnedassets) + item.opsgroup_count = item.opsgroup_count or trace_table_count(cohort and cohort.opsgroups) + return item +end + +function MOOSE_BRIDGE:_TraceOpsGroupItem(opsgroup_name, opsgroup, auftrag_id, source) + local item = self:_BuildOpsGroupSnapshotItem(opsgroup_name, opsgroup, source) + if type(item) ~= "table" then return nil end + item.current_contains_auftrag = item.auftrag_current_id == auftrag_id + item.queue_contains_auftrag = self:_TraceQueueContainsAuftrag(opsgroup and opsgroup.missionqueue, auftrag_id) + item.missionqueue_count = trace_table_count(opsgroup and opsgroup.missionqueue) + return item +end + +function MOOSE_BRIDGE:_TraceCollectLegions(auftrag_id) + local result = {} + if _DATABASE and type(_DATABASE.LEGIONS) == "table" then + for name, legion in pairs(_DATABASE.LEGIONS) do + local ok, item = pcall(function() return self:_TraceLegionItem(name, legion, auftrag_id, "database.LEGIONS") end) + if ok and item then result[#result + 1] = item end + end + end + return result +end + +function MOOSE_BRIDGE:_TraceCollectCohorts() + local result = {}; local seen = {} + if _DATABASE and type(_DATABASE.LEGIONS) == "table" then + for _, legion in pairs(_DATABASE.LEGIONS) do + if type(legion.cohorts) == "table" then + for name, cohort in pairs(legion.cohorts) do + local ok, item = pcall(function() return self:_TraceCohortItem(name, cohort, "legion.cohorts") end) + if ok and item and item.object_id and not seen[item.object_id] then + result[#result + 1] = item + seen[item.object_id] = true + end + end + end + end + end + return result +end + +function MOOSE_BRIDGE:_TraceCollectOpsGroups(auftrag_id) + local result = {}; local seen = {} + for name, opsgroup in pairs(self.RegisteredOpsGroups or {}) do + local ok, item = pcall(function() return self:_TraceOpsGroupItem(name, opsgroup, auftrag_id, "registered") end) + if ok and item and item.object_id and not seen[item.object_id] then + result[#result + 1] = item + seen[item.object_id] = true + end + end + -- MOOSE stores all OPSGROUP specializations here despite the FLIGHTGROUPS name. + if _DATABASE and type(_DATABASE.FLIGHTGROUPS) == "table" then + for name, opsgroup in pairs(_DATABASE.FLIGHTGROUPS) do + local ok, item = pcall(function() return self:_TraceOpsGroupItem(name, opsgroup, auftrag_id, "database.FLIGHTGROUPS") end) + if ok and item and item.object_id and not seen[item.object_id] then + result[#result + 1] = item + seen[item.object_id] = true + end + end + end + return result +end + +function MOOSE_BRIDGE:_TraceBuild(auftrag_id) + local auftrag, source = self:_TraceFindAuftrag(auftrag_id) + local auftrag_item = nil + if type(auftrag) == "table" then + local ok, item = pcall(function() return self:_BuildAuftragSnapshotItem(auftrag, source or "trace") end) + if ok then auftrag_item = item end + end + + local legions = self:_TraceCollectLegions(auftrag_id) + local cohorts = self:_TraceCollectCohorts() + local opsgroups = self:_TraceCollectOpsGroups(auftrag_id) + + local matching_legions = {} + for _, legion in ipairs(legions) do + if legion.missionqueue_contains_auftrag then matching_legions[#matching_legions + 1] = legion.object_id end + end + + local matching_opsgroups = {} + for _, opsgroup in ipairs(opsgroups) do + if opsgroup.current_contains_auftrag or opsgroup.queue_contains_auftrag then matching_opsgroups[#matching_opsgroups + 1] = opsgroup.object_id end + end + + return { + action="auftrag.trace", + auftrag_id=auftrag_id, + found=auftrag_item ~= nil, + source=source, + auftrag=auftrag_item, + legions=legions, + cohorts=cohorts, + opsgroups=opsgroups, + matching_legion_ids=matching_legions, + matching_opsgroup_ids=matching_opsgroups, + counts={ + legions=#legions, + cohorts=#cohorts, + opsgroups=#opsgroups, + matching_legions=#matching_legions, + matching_opsgroups=#matching_opsgroups, + }, + } +end + +function MOOSE_BRIDGE:RegisterAuftragTraceCommands() + self:RegisterCommand("auftrag.trace", function(cmd) + local p = self:_CommandParams(cmd) + local auftrag_id = p.auftrag_id or p.object_id or p.id + if type(auftrag_id) ~= "string" or auftrag_id == "" then error("auftrag.trace requires auftrag_id") end + local prefix, _ = trace_split_object_id(auftrag_id) + if prefix ~= "AUFTRAG" then error("auftrag.trace requires an AUFTRAG: object id") end + return self:_TraceBuild(auftrag_id) + end) +end + +local _moose_bridge_base_register_default_commands_for_trace = MOOSE_BRIDGE.RegisterDefaultCommands + +function MOOSE_BRIDGE:RegisterDefaultCommands() + _moose_bridge_base_register_default_commands_for_trace(self) + self:RegisterAuftragTraceCommands() +end diff --git a/Moose Development/Moose/Python/MooseBridgeDcsEventsExtension.lua b/Moose Development/Moose/Python/MooseBridgeDcsEventsExtension.lua new file mode 100644 index 000000000..0b9c42901 --- /dev/null +++ b/Moose Development/Moose/Python/MooseBridgeDcsEventsExtension.lua @@ -0,0 +1,297 @@ +--- DCS world event forwarding for MOOSE_BRIDGE. +-- +-- Load after MooseBridge.lua and before constructing/starting the bridge. +-- DCS events are normalized here; Python never needs to understand the raw +-- world event table or MOOSE EVENTDATA implementation details. + +if not MOOSE_BRIDGE then error("Load MooseBridge.lua before MooseBridgeDcsEventsExtension.lua") end + +local function bridge_event_available(event_id) + return type(event_id) == "number" and event_id > 0 +end + +--- Cache current DCS ownership for all known MOOSE AIRBASE objects. +-- The DCS BaseCaptured event already exposes the new owner, so the cache is +-- needed to include the previous owner in the normalized bridge event. +function MOOSE_BRIDGE:_CacheAirbaseCoalitions() + self.AirbaseCoalitions = self.AirbaseCoalitions or {} + if not _DATABASE or type(_DATABASE.AIRBASES) ~= "table" then return self end + for airbase_name, airbase in pairs(_DATABASE.AIRBASES) do + local ok, result = pcall(function() + local name = self:_SafeCall(airbase, "GetName") or airbase.AirbaseName or airbase_name + local owner = self:_CoalitionToName(self:_SafeCall(airbase, "GetCoalition")) + return name and {object_id="AIRBASE:" .. tostring(name), coalition=owner} or nil + end) + if ok and result and result.object_id then + self.AirbaseCoalitions[result.object_id] = result.coalition + elseif not ok then + self:_Log("Failed to cache airbase " .. tostring(airbase_name) .. ": " .. tostring(result)) + end + end + return self +end + +--- Resolve the authoritative MOOSE AIRBASE wrapper from an EVENTDATA object. +function MOOSE_BRIDGE:_AirbaseFromCapturedEvent(EventData) + if type(EventData) ~= "table" then return nil, nil end + local place = EventData.Place + local name = EventData.PlaceName + if not name and place then + name = self:_SafeCall(place, "GetName") + end + if not name and EventData.place then + local ok, value = pcall(function() return EventData.place:getName() end) + if ok then name = value end + end + if not name then return nil, nil end + + local airbase = type(place) == "table" and place or nil + if _DATABASE and type(_DATABASE.AIRBASES) == "table" then + airbase = _DATABASE.AIRBASES[name] or airbase + end + if not airbase and AIRBASE and AIRBASE.FindByName then + local ok, value = pcall(function() return AIRBASE:FindByName(name) end) + if ok then airbase = value end + end + return airbase, tostring(name) +end + +--- Subscribe to selected low-frequency DCS events through MOOSE. +function MOOSE_BRIDGE:_StartDcsEventForwarding() + if self.DcsEventForwardingStarted then return self end + if not EVENTS or not self.HandleEvent then + self:_Log("DCS event forwarding unavailable") + return self + end + self.DcsRegisteredEvents = {} + if bridge_event_available(EVENTS.BaseCaptured) then + self:_CacheAirbaseCoalitions() + self:HandleEvent(EVENTS.BaseCaptured) + self.DcsRegisteredEvents[#self.DcsRegisteredEvents + 1] = EVENTS.BaseCaptured + self:_Log("DCS BaseCaptured event forwarding enabled") + end + if bridge_event_available(EVENTS.UnitLost) then + self:HandleEvent(EVENTS.UnitLost) + self.DcsRegisteredEvents[#self.DcsRegisteredEvents + 1] = EVENTS.UnitLost + self:_Log("DCS UnitLost event forwarding enabled") + end + if bridge_event_available(EVENTS.Dead) then + self:HandleEvent(EVENTS.Dead) + self.DcsRegisteredEvents[#self.DcsRegisteredEvents + 1] = EVENTS.Dead + self:_Log("DCS Dead event forwarding enabled") + end + if bridge_event_available(EVENTS.Kill) then + self:HandleEvent(EVENTS.Kill) + self.DcsRegisteredEvents[#self.DcsRegisteredEvents + 1] = EVENTS.Kill + self:_Log("DCS Kill event forwarding enabled") + end + if bridge_event_available(EVENTS.MissionEnd) then + self:HandleEvent(EVENTS.MissionEnd) + self.DcsRegisteredEvents[#self.DcsRegisteredEvents + 1] = EVENTS.MissionEnd + self:_Log("DCS MissionEnd event forwarding enabled") + end + if #self.DcsRegisteredEvents == 0 then + self:_Log("No supported DCS events available for forwarding") + return self + end + self.DcsEventForwardingStarted = true + return self +end + +--- Unsubscribe from DCS events owned by this bridge instance. +function MOOSE_BRIDGE:_StopDcsEventForwarding() + if self.DcsEventForwardingStarted and self.UnHandleEvent then + for _, event_id in ipairs(self.DcsRegisteredEvents or {}) do + self:UnHandleEvent(event_id) + end + end + self.DcsRegisteredEvents = {} + self.DcsEventForwardingStarted = false + return self +end + +--- Forward DCS S_EVENT_BASE_CAPTURED as airbase.coalition_changed. +-- @param Core.Event#EVENTDATA EventData MOOSE-normalized DCS event data. +function MOOSE_BRIDGE:OnEventBaseCaptured(EventData) + local ok, err = pcall(function() + local airbase, airbase_name = self:_AirbaseFromCapturedEvent(EventData) + if not airbase or not airbase_name then + error("BaseCaptured event has no resolvable AIRBASE") + end + + local item = self:_BuildAirbaseSnapshotItem(airbase_name, airbase) + if not item or not item.object_id then + error("Could not build AIRBASE snapshot for " .. tostring(airbase_name)) + end + + self.AirbaseCoalitions = self.AirbaseCoalitions or {} + local previous = self.AirbaseCoalitions[item.object_id] + local current = item.coalition + self.AirbaseCoalitions[item.object_id] = current + + if previous ~= current then + self:SendEvent("airbase.coalition_changed", { + dcs_event_id=EventData.id, + dcs_event_name="S_EVENT_BASE_CAPTURED", + dcs_event_time=EventData.time, + airbase_id=item.object_id, + previous_coalition=previous, + coalition=current, + capturing_unit_id=EventData.IniUnitName and ("UNIT:" .. tostring(EventData.IniUnitName)) or nil, + capturing_group_id=EventData.IniGroupName and ("GROUP:" .. tostring(EventData.IniGroupName)) or nil, + capturing_coalition=self:_CoalitionToName(EventData.IniCoalition), + capturing_unit_type=EventData.IniTypeName and tostring(EventData.IniTypeName) or nil, + airbase=item, + }) + end + end) + if not ok then + self:_Log("Failed to forward BaseCaptured event: " .. tostring(err)) + end +end + +--- Build a tombstone and current group snapshot for a lost DCS object. +function MOOSE_BRIDGE:_BuildUnitLostPayload(EventData) + if type(EventData) ~= "table" then error("UnitLost event data is missing") end + local name = EventData.IniUnitName or EventData.IniDCSUnitName + if not name then error("UnitLost event has no initiator name") end + + local is_static = Object and Object.Category + and EventData.IniObjectCategory == Object.Category.STATIC + local object_type = is_static and "STATIC" or "UNIT" + local object_id = object_type .. ":" .. tostring(name) + local item = nil + + if is_static then + local static = EventData.IniUnit + if not static and _DATABASE and _DATABASE.STATICS then static = _DATABASE.STATICS[name] end + if static then + local ok, value = pcall(function() return self:_BuildStaticSnapshotItem(name, static) end) + if ok then item = value end + end + else + local unit = EventData.IniUnit + if not unit and _DATABASE and _DATABASE.UNITS then unit = _DATABASE.UNITS[name] end + if unit then + local ok, value = pcall(function() return self:_BuildUnitSnapshotItem(name, unit) end) + if ok then item = value end + end + end + + item = item or { + object_id=object_id, + dcs_name=tostring(name), + object_type=object_type, + } + item.object_id = object_id + item.object_type = object_type + item.alive = false + item.active = false + item.coalition = item.coalition or self:_CoalitionToName(EventData.IniCoalition) + item.category = item.category or (EventData.IniCategory and tostring(EventData.IniCategory) or nil) + item.dcs_type = item.dcs_type or (EventData.IniTypeName and tostring(EventData.IniTypeName) or nil) + + local group_name = EventData.IniGroupName or EventData.IniDCSGroupName or item.group_name + local group_item = nil + if not is_static and group_name then + local group = EventData.IniGroup + if not group and _DATABASE and _DATABASE.GROUPS then group = _DATABASE.GROUPS[group_name] end + if group then + local ok, value = pcall(function() return self:_BuildGroupSnapshotItem(group_name, group) end) + if ok then group_item = value end + end + item.group_name = tostring(group_name) + end + + return { + object_id=object_id, + object_type=object_type, + group_id=group_name and ("GROUP:" .. tostring(group_name)) or nil, + object=item, + group=group_item, + } +end + +--- Forward one DCS destruction event as object.destroyed. +-- UnitLost and Dead can describe the same loss, depending on the DCS object +-- and destruction path. Suppress the second event without polling object state. +function MOOSE_BRIDGE:_ForwardObjectDestroyed(EventData, event_name) + local ok, err = pcall(function() + local payload = self:_BuildUnitLostPayload(EventData) + local event_time = tonumber(EventData.time) + local dedup_time = event_time or (timer and timer.getTime and timer.getTime()) or 0 + self.DcsDestroyedEventTimes = self.DcsDestroyedEventTimes or {} + local previous_time = self.DcsDestroyedEventTimes[payload.object_id] + if previous_time and math.abs(dedup_time - previous_time) <= 2 then return end + self.DcsDestroyedEventTimes[payload.object_id] = dedup_time + + payload.dcs_event_id = EventData.id + payload.dcs_event_name = event_name + payload.dcs_event_time = event_time + self:SendEvent("object.destroyed", payload) + end) + if not ok then + self:_Log("Failed to forward " .. tostring(event_name) .. " event: " .. tostring(err)) + end +end + +--- Forward DCS S_EVENT_UNIT_LOST as object.destroyed. +-- @param Core.Event#EVENTDATA EventData MOOSE-normalized DCS event data. +function MOOSE_BRIDGE:OnEventUnitLost(EventData) + self:_ForwardObjectDestroyed(EventData, "S_EVENT_UNIT_LOST") +end + +--- Forward DCS S_EVENT_DEAD as object.destroyed. +-- @param Core.Event#EVENTDATA EventData MOOSE-normalized DCS event data. +function MOOSE_BRIDGE:OnEventDead(EventData) + self:_ForwardObjectDestroyed(EventData, "S_EVENT_DEAD") +end + +--- Forward an attributed DCS kill without replacing UnitLost/Dead state events. +-- @param Core.Event#EVENTDATA EventData MOOSE-normalized DCS event data. +function MOOSE_BRIDGE:OnEventKill(EventData) + local ok, err = pcall(function() + if type(EventData) ~= "table" then error("Kill event data is missing") end + local killer_name = EventData.IniUnitName or EventData.IniDCSUnitName + local target_name = EventData.TgtUnitName or EventData.TgtDCSUnitName + if not killer_name or not target_name then error("Kill event has no killer or target name") end + + local target_is_static = Object and Object.Category + and EventData.TgtObjectCategory == Object.Category.STATIC + self:SendEvent("combat.kill", { + dcs_event_id=EventData.id, + dcs_event_name="S_EVENT_KILL", + dcs_event_time=EventData.time, + killer_object_id="UNIT:" .. tostring(killer_name), + killer_group_id=EventData.IniGroupName and ("GROUP:" .. tostring(EventData.IniGroupName)) or nil, + killer_coalition=self:_CoalitionToName(EventData.IniCoalition), + killer_type=EventData.IniTypeName and tostring(EventData.IniTypeName) or nil, + target_object_id=(target_is_static and "STATIC:" or "UNIT:") .. tostring(target_name), + target_group_id=EventData.TgtGroupName and ("GROUP:" .. tostring(EventData.TgtGroupName)) or nil, + target_coalition=self:_CoalitionToName(EventData.TgtCoalition), + target_type=EventData.TgtTypeName and tostring(EventData.TgtTypeName) or nil, + weapon_name=EventData.WeaponName and tostring(EventData.WeaponName) or nil, + }) + end) + if not ok then + self:_Log("Failed to forward Kill event: " .. tostring(err)) + end +end + +--- Forward DCS S_EVENT_MISSION_END as the authoritative Python session boundary. +-- Flush immediately because normal bridge scheduling stops with the mission. +-- @param Core.Event#EVENTDATA EventData MOOSE-normalized DCS event data. +function MOOSE_BRIDGE:OnEventMissionEnd(EventData) + local ok, err = pcall(function() + self:SendEvent("mission.ended", { + dcs_event_id=EventData and EventData.id or nil, + dcs_event_name="S_EVENT_MISSION_END", + dcs_event_time=EventData and EventData.time or nil, + reason="dcs_mission_end", + }) + self:_FlushOutQueue() + end) + if not ok then + self:_Log("Failed to forward MissionEnd event: " .. tostring(err)) + end +end diff --git a/Moose Development/Moose/Python/MooseBridgeIntelExtension.lua b/Moose Development/Moose/Python/MooseBridgeIntelExtension.lua new file mode 100644 index 000000000..5024f5ef1 --- /dev/null +++ b/Moose Development/Moose/Python/MooseBridgeIntelExtension.lua @@ -0,0 +1,404 @@ +-- Optional INTEL snapshot and event extension for MOOSE Bridge. +-- +-- MOOSE remains the owner of INTEL tactical logic. This extension only mirrors +-- registered INTEL objects and forwards their FSM events to Python. + +local function bridge_intel_safe_tostring(value) + if value == nil then return nil end + return tostring(value) +end + +local function bridge_intel_object_name(object) + if not object then return nil end + if MOOSE_BRIDGE and MOOSE_BRIDGE._ObjectName then + local ok, value = pcall(function() return MOOSE_BRIDGE:_ObjectName(object) end) + if ok and value then return value end + end + if object.alias then return tostring(object.alias) end + if object.name then return tostring(object.name) end + if object.Name then return tostring(object.Name) end + if object.groupname then return tostring(object.groupname) end + return nil +end + +local function bridge_intel_auftrag_id(bridge, auftrag) + if not auftrag then return nil end + if bridge and bridge._AuftragObjectId then + local ok, value = pcall(function() return bridge:_AuftragObjectId(auftrag) end) + if ok and value then return value end + end + if auftrag.auftragsnummer then return "AUFTRAG:" .. tostring(auftrag.auftragsnummer) end + if auftrag.name then return "AUFTRAG:" .. tostring(auftrag.name) end + return nil +end + +local function bridge_intel_point(bridge, value) + if not value then return nil end + if bridge and bridge._PointFromMooseObject then + local ok, point = pcall(function() return bridge:_PointFromMooseObject(value) end) + if ok and point then return point end + end + if type(value) == "table" and type(value.x) == "number" and type(value.z) == "number" then + return value + end + return nil +end + +local function bridge_intel_velocity(value) + if type(value) ~= "table" then return nil end + return { + x=type(value.x) == "number" and value.x or nil, + y=type(value.y) == "number" and value.y or nil, + z=type(value.z) == "number" and value.z or nil, + } +end + +function MOOSE_BRIDGE:_EnsureIntelRegistry() + self.RegisteredIntels = self.RegisteredIntels or {} + return self.RegisteredIntels +end + +function MOOSE_BRIDGE:RegisterIntel(intel, name) + if not intel then return self end + local intel_name = name or bridge_intel_object_name(intel) or intel.alias + if not intel_name then return self end + intel_name = tostring(intel_name) + self:_EnsureIntelRegistry()[intel_name] = intel + self:_AttachIntelEventForwarders(intel, intel_name) + if type(intel.SetAgentAuto) ~= "function" then error("INTEL:SetAgentAuto is not available") end + intel:SetAgentAuto() + return self +end + +function MOOSE_BRIDGE:RegisterIntels(intels) + if type(intels) ~= "table" then return self end + for name, intel in pairs(intels) do self:RegisterIntel(intel, name) end + return self +end + +function MOOSE_BRIDGE:_IntelObjectId(intel_name) + return "INTEL:" .. tostring(intel_name) +end + +function MOOSE_BRIDGE:_IntelContactId(intel_name, contact) + local name = contact and contact.groupname or nil + if not name then return nil end + return "INTELCONTACT:" .. tostring(intel_name) .. ":" .. tostring(name) +end + +function MOOSE_BRIDGE:_IntelClusterId(intel_name, cluster) + local index = cluster and cluster.index or nil + if not index then return nil end + return "INTELCLUSTER:" .. tostring(intel_name) .. ":" .. tostring(index) +end + +function MOOSE_BRIDGE:_IntelContactTargetObjectId(contact) + if not contact then return nil end + local name = contact.groupname + if not name then return nil end + if contact.isStatic then return "STATIC:" .. tostring(name) end + return "GROUP:" .. tostring(name) +end + +function MOOSE_BRIDGE:_IntelAgentCounts(intel) + local detectionset = intel and intel.detectionset or nil + if not detectionset then return 0, 0 end + return detectionset:Count(), detectionset:CountAlive() +end + +function MOOSE_BRIDGE:_IntelAgentSnapshot(intel) + local result = {} + local detectionset = intel and intel.detectionset or nil + if not detectionset then return result, 0, 0 end + + for name, _ in pairs(detectionset.Set or {}) do + result[#result + 1] = "GROUP:" .. tostring(name) + end + + local agent_count, alive_agent_count = self:_IntelAgentCounts(intel) + return result, agent_count, alive_agent_count +end + +function MOOSE_BRIDGE:_ResolveRegisteredIntel(intel_id) + local name = type(intel_id) == "string" and string.match(intel_id, "^INTEL:(.+)$") or nil + if not name then return nil, nil, "Invalid INTEL object id: " .. tostring(intel_id) end + local intel = self:_EnsureIntelRegistry()[name] + if not intel then return nil, nil, "INTEL not registered: " .. name end + return intel, name, nil +end + +function MOOSE_BRIDGE:_ResolveIntelAgent(agent_id) + local prefix, name + if type(agent_id) == "string" then prefix, name = string.match(agent_id, "^([^:]+):(.+)$") end + if not prefix or not name then return nil, "Invalid agent object id: " .. tostring(agent_id) end + + if prefix == "GROUP" then + local group = GROUP and GROUP.FindByName and GROUP:FindByName(name) or nil + if not group then return nil, "GROUP not found: " .. name end + return group, nil + end + + if prefix == "OPSGROUP" then + if not self._ResolveOpsGroupById then + return nil, "OPSGROUP agents require MooseBridgeAuftragExecutionExtension.lua" + end + return self:_ResolveOpsGroupById(agent_id) + end + + return nil, "Agent must be GROUP: or OPSGROUP:" +end + +function MOOSE_BRIDGE:_BuildIntelContactSnapshotItem(intel_name, contact, source) + if type(contact) ~= "table" then return nil end + local point = bridge_intel_point(self, contact.position) + local recce_name = bridge_intel_safe_tostring(contact.recce) + local recce_unit = recce_name and UNIT and UNIT.FindByName and UNIT:FindByName(recce_name) or nil + local recce_group = recce_unit and self:_SafeCall(recce_unit, "GetGroup") or nil + local recce_group_name = recce_group and self:_SafeCall(recce_group, "GetName") or nil + local item = { + object_id=self:_IntelContactId(intel_name, contact), + dcs_name=bridge_intel_safe_tostring(contact.groupname), + object_type="INTELCONTACT", + category=bridge_intel_safe_tostring(contact.ctype or contact.categoryname), + source=source, + intel_id=self:_IntelObjectId(intel_name), + target_object_id=self:_IntelContactTargetObjectId(contact), + typename=bridge_intel_safe_tostring(contact.typename), + attribute=bridge_intel_safe_tostring(contact.attribute), + category_id=contact.category, + category_name=bridge_intel_safe_tostring(contact.categoryname), + threat_level=contact.threatlevel, + detected_time=contact.Tdetected, + recce=recce_name, + recce_unit_id=recce_name and "UNIT:" .. recce_name or nil, + recce_group_id=recce_group_name and "GROUP:" .. tostring(recce_group_name) or nil, + contact_type=bridge_intel_safe_tostring(contact.ctype), + speed_mps=contact.speed, + velocity=bridge_intel_velocity(contact.velocity), + is_ground=contact.isground and true or false, + is_ship=contact.isship and true or false, + is_static=contact.isStatic and true or false, + platform=bridge_intel_safe_tostring(contact.platform), + heading=contact.heading, + maneuvering=contact.maneuvering and true or false, + altitude_m=contact.altitude, + rcs=contact.rcs, + mission_id=bridge_intel_auftrag_id(self, contact.mission), + } + if point then self:_AddPointFields(item, point) end + return item +end + +function MOOSE_BRIDGE:_BuildIntelClusterSnapshotItem(intel_name, cluster, source) + if type(cluster) ~= "table" then return nil end + local point = bridge_intel_point(self, cluster.coordinate) + local contact_ids = {} + if type(cluster.Contacts) == "table" then + for _, contact in pairs(cluster.Contacts) do + local contact_id = self:_IntelContactId(intel_name, contact) + if contact_id then contact_ids[#contact_ids + 1] = contact_id end + end + end + local item = { + object_id=self:_IntelClusterId(intel_name, cluster), + dcs_name="Cluster " .. tostring(cluster.index or "?"), + object_type="INTELCLUSTER", + category=bridge_intel_safe_tostring(cluster.ctype), + source=source, + intel_id=self:_IntelObjectId(intel_name), + index=cluster.index, + size=cluster.size, + contact_ids=contact_ids, + threat_level_max=cluster.threatlevelMax, + threat_level_sum=cluster.threatlevelSum, + threat_level_avg=cluster.threatlevelAve, + contact_type=bridge_intel_safe_tostring(cluster.ctype), + altitude_m=cluster.altitude, + mission_id=bridge_intel_auftrag_id(self, cluster.mission), + } + if point then self:_AddPointFields(item, point) end + return item +end + +function MOOSE_BRIDGE:_BuildIntelSnapshotItem(intel_name, intel, source) + local contacts = self:_SafeCall(intel, "GetContactTable") or intel.Contacts or {} + local clusters = self:_SafeCall(intel, "GetClusterTable") or intel.Clusters or {} + local agent_ids, agent_count, alive_agent_count = self:_IntelAgentSnapshot(intel) + return { + object_id=self:_IntelObjectId(intel_name), + dcs_name=tostring(intel_name), + object_type="INTEL", + category="INTEL", + source=source, + alias=bridge_intel_safe_tostring(intel.alias), + coalition=self:_CoalitionToName(intel.coalition), + state=self:_SafeCallArg(intel, "GetState") or nil, + is_running=self:_SafeCallArg(intel, "Is", "Running") and true or false, + cluster_analysis=intel.clusteranalysis and true or false, + cluster_markers=intel.clustermarkers and true or false, + cluster_arrows=intel.clusterarrows and true or false, + cluster_radius_m=intel.clusterradius, + detect_statics=intel.detectStatics and true or false, + detect_accoustic=intel.DetectAccoustic and true or false, + detect_accoustic_radius_m=intel.DetectAccousticRadius, + doppler_radar=intel.DopplerRadar and true or false, + contact_count=type(contacts) == "table" and #contacts or 0, + cluster_count=type(clusters) == "table" and #clusters or 0, + agent_count=agent_count, + alive_agent_count=alive_agent_count, + agent_ids=agent_ids, + } +end + +function MOOSE_BRIDGE:BuildIntelSnapshot() + local result = {} + for name, intel in pairs(self:_EnsureIntelRegistry()) do + local ok, item = pcall(function() return self:_BuildIntelSnapshotItem(name, intel, "registered") end) + if ok and item then result[#result + 1] = item end + end + return result +end + +function MOOSE_BRIDGE:BuildIntelContactSnapshot() + local result = {} + for name, intel in pairs(self:_EnsureIntelRegistry()) do + local contacts = self:_SafeCall(intel, "GetContactTable") or intel.Contacts or {} + if type(contacts) == "table" then + for _, contact in pairs(contacts) do + local ok, item = pcall(function() return self:_BuildIntelContactSnapshotItem(name, contact, "registered") end) + if ok and item and item.object_id then result[#result + 1] = item end + end + end + end + return result +end + +function MOOSE_BRIDGE:BuildIntelClusterSnapshot() + local result = {} + for name, intel in pairs(self:_EnsureIntelRegistry()) do + local clusters = self:_SafeCall(intel, "GetClusterTable") or intel.Clusters or {} + if type(clusters) == "table" then + for _, cluster in pairs(clusters) do + local ok, item = pcall(function() return self:_BuildIntelClusterSnapshotItem(name, cluster, "registered") end) + if ok and item and item.object_id then result[#result + 1] = item end + end + end + end + return result +end + +function MOOSE_BRIDGE:_SendIntelEvent(event_name, intel_name, fsm_event, from_state, to_state, item_kind, item) + local payload = { + event=event_name, + intel_id=self:_IntelObjectId(intel_name), + fsm_event=fsm_event, + from_state=from_state, + to_state=to_state, + } + if item_kind == "contact" then + payload.contact = item + payload.contact_id = item and item.object_id or nil + payload.target_object_id = item and item.target_object_id or nil + elseif item_kind == "cluster" then + payload.cluster = item + payload.cluster_id = item and item.object_id or nil + end + self:SendEvent(event_name, payload) +end + +function MOOSE_BRIDGE:_AttachIntelEventForwarders(intel, intel_name) + if type(intel) ~= "table" or intel.MooseBridgeIntelEventsRegistered then return self end + intel.MooseBridgeIntelEventsRegistered = true + local bridge = self + local previous_new_contact = intel.OnAfterNewContact + local previous_lost_contact = intel.OnAfterLostContact + local previous_new_cluster = intel.OnAfterNewCluster + local previous_lost_cluster = intel.OnAfterLostCluster + + intel.OnAfterNewContact = function(intel_self, From, Event, To, Contact) + if type(previous_new_contact) == "function" then pcall(previous_new_contact, intel_self, From, Event, To, Contact) end + local item = bridge:_BuildIntelContactSnapshotItem(intel_name, Contact, "event") + bridge:_SendIntelEvent("intel.new_contact", intel_name, Event, From, To, "contact", item) + end + + intel.OnAfterLostContact = function(intel_self, From, Event, To, Contact) + if type(previous_lost_contact) == "function" then pcall(previous_lost_contact, intel_self, From, Event, To, Contact) end + local item = bridge:_BuildIntelContactSnapshotItem(intel_name, Contact, "event") + bridge:_SendIntelEvent("intel.lost_contact", intel_name, Event, From, To, "contact", item) + end + + intel.OnAfterNewCluster = function(intel_self, From, Event, To, Cluster) + if type(previous_new_cluster) == "function" then pcall(previous_new_cluster, intel_self, From, Event, To, Cluster) end + local item = bridge:_BuildIntelClusterSnapshotItem(intel_name, Cluster, "event") + bridge:_SendIntelEvent("intel.new_cluster", intel_name, Event, From, To, "cluster", item) + end + + intel.OnAfterLostCluster = function(intel_self, From, Event, To, Cluster, Mission) + if type(previous_lost_cluster) == "function" then pcall(previous_lost_cluster, intel_self, From, Event, To, Cluster, Mission) end + local item = bridge:_BuildIntelClusterSnapshotItem(intel_name, Cluster, "event") + if item and not item.mission_id then item.mission_id = bridge_intel_auftrag_id(bridge, Mission) end + bridge:_SendIntelEvent("intel.lost_cluster", intel_name, Event, From, To, "cluster", item) + end + + return self +end + +local _moose_bridge_base_register_default_commands_for_intel = MOOSE_BRIDGE.RegisterDefaultCommands + +function MOOSE_BRIDGE:RegisterDefaultCommands() + _moose_bridge_base_register_default_commands_for_intel(self) + + local previous_snapshot_all = self.CommandHandlers["snapshot.all"] + + self:RegisterCommand("snapshot.intels", function(cmd) + local intels = self:BuildIntelSnapshot() + self:SendSnapshot("intels", {intels=intels}) + return {kind="intels", count=#intels} + end) + + self:RegisterCommand("intel.add_agent", function(cmd) + local params = self:_CommandParams(cmd) + local intel, intel_name, intel_err = self:_ResolveRegisteredIntel(params.intel_id) + if not intel then error(intel_err) end + local agent, agent_err = self:_ResolveIntelAgent(params.agent_id) + if not agent then error(agent_err) end + + intel:AddAgent(agent) + local agent_count, alive_agent_count = self:_IntelAgentCounts(intel) + return { + action="intel.add_agent", + intel_id=self:_IntelObjectId(intel_name), + agent_id=params.agent_id, + agent_count=agent_count, + alive_agent_count=alive_agent_count, + } + end) + + self:RegisterCommand("snapshot.intel_contacts", function(cmd) + local contacts = self:BuildIntelContactSnapshot() + self:SendSnapshot("intel_contacts", {intel_contacts=contacts}) + return {kind="intel_contacts", count=#contacts} + end) + + self:RegisterCommand("snapshot.intel_clusters", function(cmd) + local clusters = self:BuildIntelClusterSnapshot() + self:SendSnapshot("intel_clusters", {intel_clusters=clusters}) + return {kind="intel_clusters", count=#clusters} + end) + + if previous_snapshot_all then + self:RegisterCommand("snapshot.all", function(cmd) + local result = previous_snapshot_all(cmd) or {} + local intels = self:BuildIntelSnapshot() + local contacts = self:BuildIntelContactSnapshot() + local clusters = self:BuildIntelClusterSnapshot() + self:SendSnapshot("intels", {intels=intels}) + self:SendSnapshot("intel_contacts", {intel_contacts=contacts}) + self:SendSnapshot("intel_clusters", {intel_clusters=clusters}) + result.intels = #intels + result.intel_contacts = #contacts + result.intel_clusters = #clusters + return result + end) + end +end diff --git a/Moose Development/Moose/Python/MooseBridgeJson.lua b/Moose Development/Moose/Python/MooseBridgeJson.lua new file mode 100644 index 000000000..88fd890dd --- /dev/null +++ b/Moose Development/Moose/Python/MooseBridgeJson.lua @@ -0,0 +1,258 @@ +--- Minimal JSON helper for the MOOSE Bridge V1 prototype. +-- This is deliberately small and covers the V1 command/ack/heartbeat/snapshot payloads. + +MOOSE_BRIDGE_JSON = MOOSE_BRIDGE_JSON or {} +local json = MOOSE_BRIDGE_JSON + +local function escape(value) + value = tostring(value or "") + value = value:gsub('\\', '\\\\') + value = value:gsub('"', '\\"') + value = value:gsub('\n', '\\n') + value = value:gsub('\r', '\\r') + value = value:gsub('\t', '\\t') + return value +end + +local function is_array(value) + if type(value) ~= "table" then + return false + end + + local max_index = 0 + local count = 0 + + for key, _ in pairs(value) do + if type(key) ~= "number" or key < 1 or key % 1 ~= 0 then + return false + end + if key > max_index then + max_index = key + end + count = count + 1 + end + + return count == max_index +end + +local function encode_value(value) + local t = type(value) + + if value == nil then + return "null" + elseif t == "boolean" then + return value and "true" or "false" + elseif t == "number" then + return tostring(value) + elseif t == "string" then + return '"' .. escape(value) .. '"' + elseif t == "table" then + local parts = {} + + if is_array(value) then + for index = 1, #value do + parts[#parts + 1] = encode_value(value[index]) + end + return "[" .. table.concat(parts, ",") .. "]" + end + + for k, v in pairs(value) do + parts[#parts + 1] = '"' .. escape(k) .. '":' .. encode_value(v) + end + return "{" .. table.concat(parts, ",") .. "}" + end + + return '"' .. escape(value) .. '"' +end + +function json.encode(value) + return encode_value(value) +end + +local function decode_error(text, index, message) + error("JSON decode error at byte " .. tostring(index) .. ": " .. message .. " near " .. string.format("%q", text:sub(index, index + 20))) +end + +local function skip_ws(text, index) + while index <= #text do + local char = text:sub(index, index) + if char ~= " " and char ~= "\n" and char ~= "\r" and char ~= "\t" then break end + index = index + 1 + end + return index +end + +local function utf8_char(codepoint) + if codepoint <= 0x7F then + return string.char(codepoint) + elseif codepoint <= 0x7FF then + return string.char( + 0xC0 + math.floor(codepoint / 0x40), + 0x80 + (codepoint % 0x40) + ) + elseif codepoint <= 0xFFFF then + return string.char( + 0xE0 + math.floor(codepoint / 0x1000), + 0x80 + (math.floor(codepoint / 0x40) % 0x40), + 0x80 + (codepoint % 0x40) + ) + elseif codepoint <= 0x10FFFF then + return string.char( + 0xF0 + math.floor(codepoint / 0x40000), + 0x80 + (math.floor(codepoint / 0x1000) % 0x40), + 0x80 + (math.floor(codepoint / 0x40) % 0x40), + 0x80 + (codepoint % 0x40) + ) + end + return "?" +end + +local parse_value + +local function parse_string(text, index) + if text:sub(index, index) ~= '"' then decode_error(text, index, "expected string") end + index = index + 1 + local parts = {} + local start = index + + while index <= #text do + local char = text:sub(index, index) + if char == '"' then + parts[#parts + 1] = text:sub(start, index - 1) + return table.concat(parts), index + 1 + elseif char == "\\" then + parts[#parts + 1] = text:sub(start, index - 1) + local escape_char = text:sub(index + 1, index + 1) + if escape_char == '"' or escape_char == "\\" or escape_char == "/" then + parts[#parts + 1] = escape_char + index = index + 2 + elseif escape_char == "b" then + parts[#parts + 1] = "\b" + index = index + 2 + elseif escape_char == "f" then + parts[#parts + 1] = "\f" + index = index + 2 + elseif escape_char == "n" then + parts[#parts + 1] = "\n" + index = index + 2 + elseif escape_char == "r" then + parts[#parts + 1] = "\r" + index = index + 2 + elseif escape_char == "t" then + parts[#parts + 1] = "\t" + index = index + 2 + elseif escape_char == "u" then + local hex = text:sub(index + 2, index + 5) + local codepoint = tonumber(hex, 16) + if not codepoint then decode_error(text, index, "invalid unicode escape") end + index = index + 6 + + if codepoint >= 0xD800 and codepoint <= 0xDBFF and text:sub(index, index + 1) == "\\u" then + local low = tonumber(text:sub(index + 2, index + 5), 16) + if low and low >= 0xDC00 and low <= 0xDFFF then + codepoint = 0x10000 + ((codepoint - 0xD800) * 0x400) + (low - 0xDC00) + index = index + 6 + end + end + + parts[#parts + 1] = utf8_char(codepoint) + else + decode_error(text, index, "invalid escape sequence") + end + start = index + else + index = index + 1 + end + end + + decode_error(text, index, "unterminated string") +end + +local function parse_number(text, index) + local start = index + if text:sub(index, index) == "-" then index = index + 1 end + while text:sub(index, index):match("%d") do index = index + 1 end + if text:sub(index, index) == "." then + index = index + 1 + while text:sub(index, index):match("%d") do index = index + 1 end + end + local exponent = text:sub(index, index) + if exponent == "e" or exponent == "E" then + index = index + 1 + local sign = text:sub(index, index) + if sign == "+" or sign == "-" then index = index + 1 end + while text:sub(index, index):match("%d") do index = index + 1 end + end + local raw = text:sub(start, index - 1) + local value = tonumber(raw) + if value == nil then decode_error(text, start, "invalid number") end + return value, index +end + +local function parse_array(text, index) + index = skip_ws(text, index + 1) + local result = {} + if text:sub(index, index) == "]" then return result, index + 1 end + + while index <= #text do + local value + value, index = parse_value(text, index) + result[#result + 1] = value + index = skip_ws(text, index) + local char = text:sub(index, index) + if char == "]" then return result, index + 1 end + if char ~= "," then decode_error(text, index, "expected ',' or ']'") end + index = skip_ws(text, index + 1) + end + + decode_error(text, index, "unterminated array") +end + +local function parse_object(text, index) + index = skip_ws(text, index + 1) + local result = {} + if text:sub(index, index) == "}" then return result, index + 1 end + + while index <= #text do + local key + key, index = parse_string(text, index) + index = skip_ws(text, index) + if text:sub(index, index) ~= ":" then decode_error(text, index, "expected ':'") end + index = skip_ws(text, index + 1) + local value + value, index = parse_value(text, index) + result[key] = value + index = skip_ws(text, index) + local char = text:sub(index, index) + if char == "}" then return result, index + 1 end + if char ~= "," then decode_error(text, index, "expected ',' or '}'") end + index = skip_ws(text, index + 1) + end + + decode_error(text, index, "unterminated object") +end + +parse_value = function(text, index) + index = skip_ws(text, index) + local char = text:sub(index, index) + if char == '"' then return parse_string(text, index) end + if char == "{" then return parse_object(text, index) end + if char == "[" then return parse_array(text, index) end + if char == "-" or char:match("%d") then return parse_number(text, index) end + if text:sub(index, index + 3) == "true" then return true, index + 4 end + if text:sub(index, index + 4) == "false" then return false, index + 5 end + if text:sub(index, index + 3) == "null" then return nil, index + 4 end + decode_error(text, index, "unexpected value") +end + +function json.decode(text) + if type(text) ~= "string" then error("JSON decode expects a string") end + local value, index = parse_value(text, 1) + index = skip_ws(text, index) + if index <= #text then decode_error(text, index, "trailing characters") end + if type(value) ~= "table" then error("JSON command must decode to an object") end + if type(value.params) ~= "table" then value.params = {} end + return value +end + +return json diff --git a/Moose Development/Moose/Python/MooseBridgePayloadExtension.lua b/Moose Development/Moose/Python/MooseBridgePayloadExtension.lua new file mode 100644 index 000000000..c189ba407 --- /dev/null +++ b/Moose Development/Moose/Python/MooseBridgePayloadExtension.lua @@ -0,0 +1,105 @@ +-- Optional AIRWING payload snapshot extension for MOOSE Bridge. +-- +-- Load after MooseBridge.lua when AIRWING payload availability should be included +-- in COHORT snapshots. The core Python advisory layer can then reject AIRWING +-- candidates without a compatible payload for the requested AUFTRAG type. + +if not MOOSE_BRIDGE then error("Load MooseBridge.lua before MooseBridgePayloadExtension.lua") end + +local function bridge_safe_tostring(value) + if value == nil then return "nil" end + return tostring(value) +end + +local function bridge_string_or_nil(value) + if value == nil then return nil end + return tostring(value) +end + +function MOOSE_BRIDGE:_CohortUnitType(cohort) + if not cohort then return nil end + local unit_type = self:_SafeCall(cohort, "GetUnitType") + if not unit_type then unit_type = self:_SafeCall(cohort, "GetTypeName") end + if not unit_type then unit_type = self:_SafeCall(cohort, "GetType") end + if not unit_type then unit_type = cohort.unittype or cohort.unitType or cohort.aircrafttype or cohort.AircraftType or cohort.type end + return unit_type and bridge_safe_tostring(unit_type) or nil +end + +function MOOSE_BRIDGE:_PayloadPerformance(payload, mission_type) + if type(payload) ~= "table" or type(payload.capabilities) ~= "table" then return nil end + for _, capability in pairs(payload.capabilities) do + if type(capability) == "table" and capability.MissionType == mission_type then + return self:_NumberOrNil(capability.Performance) + end + end + return nil +end + +function MOOSE_BRIDGE:_SummarizePayload(payload, mission_type) + if type(payload) ~= "table" then return nil end + local performance = self:_PayloadPerformance(payload, mission_type) + return { + uid=payload.uid, + unitname=bridge_string_or_nil(payload.unitname), + aircrafttype=bridge_string_or_nil(payload.aircrafttype), + navail=self:_NumberOrNil(payload.navail), + unlimited=self:_BoolOrFalse(payload.unlimited), + performance=performance, + } +end + +function MOOSE_BRIDGE:_PayloadAvailabilityForMission(airwing, unit_type, mission_type) + local payloads = self:_SafeCallArg(airwing, "_FilterPlayloads", unit_type, mission_type) + if payloads == nil then payloads = self:_SafeCallArg(airwing, "_FilterPayloads", unit_type, mission_type) end + + local result = { + available_count=0, + total_available=0, + unlimited_count=0, + best_performance=nil, + payloads={}, + } + + if type(payloads) ~= "table" then return result end + + for _, payload in pairs(payloads) do + local item = self:_SummarizePayload(payload, mission_type) + if item then + result.payloads[#result.payloads + 1] = item + result.available_count = result.available_count + 1 + if item.unlimited then result.unlimited_count = result.unlimited_count + 1 end + if item.navail then result.total_available = result.total_available + item.navail end + if item.performance and (not result.best_performance or item.performance > result.best_performance) then + result.best_performance = item.performance + end + end + end + + return result +end + +function MOOSE_BRIDGE:_CollectPayloadAvailability(cohort, mission_types) + local result = {} + if not cohort or type(mission_types) ~= "table" then return result end + local airwing = cohort.legion + if not airwing or not self:_SafeCall(airwing, "IsAirwing") then return result end + local unit_type = self:_CohortUnitType(cohort) + if not unit_type then return result end + + for _, mission_type in pairs(mission_types) do + result[bridge_safe_tostring(mission_type)] = self:_PayloadAvailabilityForMission(airwing, unit_type, mission_type) + end + + return result +end + +local _moose_bridge_base_build_cohort_snapshot_item = MOOSE_BRIDGE._BuildCohortSnapshotItem + +function MOOSE_BRIDGE:_BuildCohortSnapshotItem(cohort_name, cohort, source) + local item = _moose_bridge_base_build_cohort_snapshot_item(self, cohort_name, cohort, source) + if not item then return nil end + local unit_type = self:_CohortUnitType(cohort) + item.unit_type = unit_type + item.payloads_by_mission = self:_CollectPayloadAvailability(cohort, item.mission_types) + return item +end diff --git a/Moose Development/Moose/Python/MooseBridgeSocketTuningExtension.lua b/Moose Development/Moose/Python/MooseBridgeSocketTuningExtension.lua new file mode 100644 index 000000000..dc77ef3f0 --- /dev/null +++ b/Moose Development/Moose/Python/MooseBridgeSocketTuningExtension.lua @@ -0,0 +1,73 @@ +-- Optional connection tuning extension for MOOSE Bridge. +-- +-- Load after MooseBridge.lua and before creating the bridge instance. It reduces +-- DCS main-thread stalls when the Python server is not running by shortening the +-- blocking LuaSocket connect timeout and using less aggressive retry defaults. + +if not MOOSE_BRIDGE then error("Load MooseBridge.lua before MooseBridgeSocketTuningExtension.lua") end + +local function bridge_tuning_mission_time() + if timer and timer.getTime then return timer.getTime() end + return 0 +end + +local function bridge_tuning_safe_tostring(value) + if value == nil then return "nil" end + return tostring(value) +end + +local function bridge_should_log_connect_failure(bridge, err, now) + if bridge.LogConnectFailures == false then return false end + if err == "timeout" and bridge.LogConnectTimeouts ~= true then return false end + + local interval = bridge.ConnectFailureLogInterval or 60 + if bridge.LastConnectFailureLog and now - bridge.LastConnectFailureLog < interval then return false end + + bridge.LastConnectFailureLog = now + return true +end + +local _moose_bridge_tuning_base_new = MOOSE_BRIDGE.New + +function MOOSE_BRIDGE:New(host, port) + local bridge = _moose_bridge_tuning_base_new(self, host, port) + + -- Keep idle bridge retries low-impact when the Python server is not listening. + bridge.ConnectTimeout = bridge.ConnectTimeout or 0.02 + if bridge.ConnectRetryDelay == nil or bridge.ConnectRetryDelay == 5 then bridge.ConnectRetryDelay = 10 end + if bridge.TickInterval == nil or bridge.TickInterval == 0.2 then bridge.TickInterval = 0.5 end + if bridge.HeartbeatInterval == nil or bridge.HeartbeatInterval == 5 then bridge.HeartbeatInterval = 10 end + + -- Keep expected idle reconnect timeouts out of dcs.log by default. + if bridge.LogConnectFailures == nil then bridge.LogConnectFailures = true end + if bridge.LogConnectTimeouts == nil then bridge.LogConnectTimeouts = false end + bridge.ConnectFailureLogInterval = bridge.ConnectFailureLogInterval or 60 + bridge.LastConnectFailureLog = nil + + return bridge +end + +function MOOSE_BRIDGE:_Connect() + local now = bridge_tuning_mission_time() + if now - self.LastConnectAttempt < (self.ConnectRetryDelay or 10) then return end + self.LastConnectAttempt = now + + local lib = require("socket") + local conn = lib.tcp() + conn:settimeout(self.ConnectTimeout or 0.02) + + local ok, err = conn:connect(self.Host, self.Port) + if not ok then + if bridge_should_log_connect_failure(self, err, now) then + self:_Log("Connect failed: " .. bridge_tuning_safe_tostring(err)) + end + conn:close() + return + end + + -- All regular bridge IO is polled from the scheduler tick and must not block DCS. + conn:settimeout(0) + self.Socket = conn + self.Connected = true + self:_Log("Connected to Python bridge") +end