diff --git a/Moose Development/Moose/Ops/OpsGroup.lua b/Moose Development/Moose/Ops/OpsGroup.lua index 37dc72c4c..4c46ed0f4 100644 --- a/Moose Development/Moose/Ops/OpsGroup.lua +++ b/Moose Development/Moose/Ops/OpsGroup.lua @@ -563,6 +563,8 @@ function OPSGROUP:New(group) self:E(self.lid.."ERROR: GROUP does not exist! Returning nil") return nil end + else + self:E(self.lid.."ERROR: GROUP is nil!") end if UTILS.IsInstanceOf(group,"OPSGROUP") then @@ -575,10 +577,13 @@ function OPSGROUP:New(group) -- Set DCS group and controller. self.dcsgroup=self:GetDCSGroup() - self.controller=self.dcsgroup:getController() + if self.dcsgroup then + self.controller=self.dcsgroup:getController() + end -- Category. - self.category=self.dcsgroup:getCategory() + --self.category=self.dcsgroup:getCategory() + self.category=self.group:GetCategory() if self.category==Group.Category.GROUND then self.isArmygroup=true elseif self.category==Group.Category.TRAIN then diff --git a/Moose Development/Moose/Python/MooseBridge.lua b/Moose Development/Moose/Python/MooseBridge.lua index 3a5dd5a4a..e421b5f89 100644 --- a/Moose Development/Moose/Python/MooseBridge.lua +++ b/Moose Development/Moose/Python/MooseBridge.lua @@ -880,8 +880,9 @@ function MOOSE_BRIDGE:_BuildGroupSnapshotItem(group_name, group) local active = self:_SafeCall(group, "IsActive") local unit_count = self:_CountGroupUnits(group, false) local alive_unit_count = self:_CountGroupUnits(group, true) + local threat_level = self:_SafeCall(group, "GetThreatLevel") 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)} + 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),threat_level=self:_NumberOrZero(threat_level)} if point then self:_AddPointFields(item, point) end return item end @@ -1700,6 +1701,7 @@ function MOOSE_BRIDGE:_BuildLegionSnapshotItem(legion_name, legion, source) if not name then return nil end local point = self:_PointFromMooseObject(legion) local airbase = self:_SafeCall(legion, "GetAirbase") + local home_base_name = self:_SafeCall(legion, "GetAirbaseName") or self:_ObjectName(airbase) local item = { object_id="LEGION:"..safe_tostring(name), dcs_name=safe_tostring(name), @@ -1712,7 +1714,9 @@ function MOOSE_BRIDGE:_BuildLegionSnapshotItem(legion_name, legion, source) 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)), + airbase_name=string_or_nil(home_base_name), + home_base_id=home_base_name and "AIRBASE:"..safe_tostring(home_base_name) or nil, + home_base_name=string_or_nil(home_base_name), cohort_ids=self:_CollectCohortIds(legion and legion.cohorts), cohorts=self:_BuildCohortSummaries(legion and legion.cohorts), n_cohorts=self:_CountTable((legion and legion.cohorts) or {}), @@ -1982,6 +1986,19 @@ function MOOSE_BRIDGE:RegisterDefaultCommands() return {action="time.get", mission_time=mission_time(), dcs_time=dcs_time(), mission_date=self.MissionDate, wall_time=wall_time()} end) + self:RegisterCommand("mission.info", function(cmd) + local mission = env and env.mission or nil + return { + action="mission.info", + theater_id=mission and mission.theatre or nil, + mission_name=mission and mission.name or nil, + 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() diff --git a/Moose Development/Moose/Python/MooseBridgeDcsEventsExtension.lua b/Moose Development/Moose/Python/MooseBridgeDcsEventsExtension.lua index 1414330bd..a2f24c564 100644 --- a/Moose Development/Moose/Python/MooseBridgeDcsEventsExtension.lua +++ b/Moose Development/Moose/Python/MooseBridgeDcsEventsExtension.lua @@ -85,6 +85,16 @@ function MOOSE_BRIDGE:_StartDcsEventForwarding() self.DcsRegisteredEvents[#self.DcsRegisteredEvents + 1] = EVENTS.Kill self:_Log("DCS Kill event forwarding enabled") end + if bridge_event_available(EVENTS.PlayerEnterAircraft) then + self:HandleEvent(EVENTS.PlayerEnterAircraft) + self.DcsRegisteredEvents[#self.DcsRegisteredEvents + 1] = EVENTS.PlayerEnterAircraft + self:_Log("MOOSE PlayerEnterAircraft event forwarding enabled") + end + if bridge_event_available(EVENTS.PlayerLeaveUnit) then + self:HandleEvent(EVENTS.PlayerLeaveUnit) + self.DcsRegisteredEvents[#self.DcsRegisteredEvents + 1] = EVENTS.PlayerLeaveUnit + self:_Log("DCS PlayerLeaveUnit event forwarding enabled") + end if bridge_event_available(EVENTS.MarkAdded) then self:HandleEvent(EVENTS.MarkAdded) self.DcsRegisteredEvents[#self.DcsRegisteredEvents + 1] = EVENTS.MarkAdded @@ -115,6 +125,8 @@ end --- Unsubscribe from DCS events owned by this bridge instance. function MOOSE_BRIDGE:_StopDcsEventForwarding() + self.PlayerTestMenuConfig = nil + self:_ClearPlayerTestMenus() if self.DcsEventForwardingStarted and self.UnHandleEvent then for _, event_id in ipairs(self.DcsRegisteredEvents or {}) do self:UnHandleEvent(event_id) @@ -122,6 +134,9 @@ function MOOSE_BRIDGE:_StopDcsEventForwarding() end self.DcsRegisteredEvents = {} self.DcsEventForwardingStarted = false + self.PlayerAircraftSessions = {} + self.PlayerAircraftLeaveTimes = {} + self.PendingPlayerAircraftEnters = {} return self end @@ -312,6 +327,621 @@ function MOOSE_BRIDGE:OnEventKill(EventData) end end +--- Resolve the OPSGROUP specialization belonging to a player's DCS group. +-- FLIGHTGROUP inherits OPSGROUP and MOOSE stores every OPSGROUP specialization +-- in DATABASE.FLIGHTGROUPS despite that collection's historical name. +function MOOSE_BRIDGE:_PlayerEventOpsGroup(group_name) + if not group_name then return nil, nil end + local opsgroup = self.RegisteredOpsGroups and self.RegisteredOpsGroups[group_name] or nil + local source = opsgroup and "registered" or nil + if not opsgroup and _DATABASE and type(_DATABASE.FLIGHTGROUPS) == "table" then + opsgroup = _DATABASE.FLIGHTGROUPS[group_name] + source = opsgroup and "database.FLIGHTGROUPS" or nil + end + return opsgroup, source +end + + +--- Read a FLIGHTGROUP route without changing its waypoints or DCS tasks. +-- waypoints0 is OPSGROUP's preserved Mission Editor route, including landing. +-- GetWaypoints() instead returns the processed/current operational route. +function MOOSE_BRIDGE:_GetFlightGroupRoute(params) + local object_id = params.opsgroup_id + if type(object_id) ~= "string" or not string.find(object_id, "^OPSGROUP:.+") then + error("flightgroup.route.get requires an OPSGROUP: id") + end + local group_name = string.sub(object_id, 10) + local opsgroup = self:_PlayerEventOpsGroup(group_name) + if not opsgroup then error("OPSGROUP not found: " .. object_id) end + if not self:_SafeCall(opsgroup, "IsFlightgroup") then + error("OPSGROUP is not a FLIGHTGROUP: " .. object_id) + end + local source = params.route_source or "mission_editor" + local waypoints + if source == "mission_editor" then + waypoints = opsgroup.waypoints0 + elseif source == "current" then + waypoints = self:_SafeCall(opsgroup, "GetWaypoints") + else + error("route_source must be mission_editor or current") + end + if type(waypoints) ~= "table" or #waypoints == 0 then + error("No " .. source .. " waypoints available for " .. object_id) + end + if #waypoints > 501 then error("Flight route exceeds the 501-waypoint display limit") end + local items = {} + for index, waypoint in ipairs(waypoints) do + local x = tonumber(waypoint.x) + local z = tonumber(waypoint.y) -- DCS route Vec2.y is world Vec3.z, not altitude. + local altitude = tonumber(waypoint.alt) + if not x or not z then error("Invalid coordinates at waypoint " .. tostring(index)) end + local coordinates = self:_CoordinatesForPoint({x=x, y=0, z=z}, "ll") + if not coordinates.latitude or not coordinates.longitude then + error("Cannot convert waypoint " .. tostring(index) .. " to latitude/longitude") + end + items[#items + 1] = { + index=index, + uid=waypoint.uid, + name=waypoint.name or ("WP " .. tostring(index)), + x=x, + z=z, + latitude=coordinates.latitude, + longitude=coordinates.longitude, + altitude_m=altitude, + altitude_type=waypoint.alt_type, + speed_mps=tonumber(waypoint.speed), + type=waypoint.type, + action=waypoint.action, + } + end + return { + opsgroup_id=object_id, + group_id="GROUP:" .. group_name, + coalition=self:_OpsCoalition(opsgroup), + route_source=source, + waypoints=items, + } +end + +-- Compose with the other extensions' command registration before Bridge:New(). +local _player_route_register_default_commands = MOOSE_BRIDGE.RegisterDefaultCommands +function MOOSE_BRIDGE:RegisterDefaultCommands() + _player_route_register_default_commands(self) + self:RegisterCommand("flightgroup.route.get", function(cmd) + return self:_GetFlightGroupRoute(cmd.params or {}) + end) + self:RegisterCommand("player.menu.test.configure", function(cmd) + return self:_ConfigurePlayerTestMenus(cmd.params or {}) + end) + self:RegisterCommand("player.menu.navigation.configure", function(cmd) + return self:_ConfigurePlayerTestMenus(cmd.params or {}, "navigation") + end) + self:RegisterCommand("player.menu.navigation.context", function(cmd) + local params = cmd.params or {} + local entry = self:_NavigationMenuEntry(params) + return self:_NavigationMenuPayload(entry.group:GetName(), entry) + end) + self:RegisterCommand("player.menu.navigation.flight_status", function(cmd) + return self:_GetPlayerFlightStatus(cmd.params or {}) + end) + self:RegisterCommand("player.menu.navigation.message", function(cmd) + local params = cmd.params or {} + local entry = self:_NavigationMenuEntry(params) + if params.unit_id ~= nil then + local unit_name = self:_FlightStatusReferenceUnit(entry) + if params.unit_id ~= "UNIT:" .. unit_name then error("Flight status reference aircraft changed") end + end + if type(params.text) ~= "string" or #params.text == 0 or #params.text > 2000 then + error("navigation message text must contain 1..2000 bytes") + end + MESSAGE:New(params.text, 10, "Navigation"):ToGroup(entry.group) + return {delivered=true} + end) + self:RegisterCommand("player.menu.navigation.overlay", function(cmd) + local params = cmd.params or {} + local entry = self:_NavigationMenuEntry(params) + if type(params.show) ~= "boolean" then error("show must be boolean") end + if not params.show then + return {removed=self:_ClearDebugOverlay(entry.overlay_id)} + end + local coalition_name = self:_CoalitionToName(self:_SafeCall(entry.group, "GetCoalition")) + if coalition_name ~= "blue" and coalition_name ~= "red" and coalition_name ~= "neutral" then + error("Cannot determine navigation overlay coalition") + end + return self:_DrawDebugOverlay({overlay_id=entry.overlay_id, features=params.features, + coalition=coalition_name, replace=true, read_only=true}) + end) +end + +--- Validate at execution time so delayed Python work cannot address a new slot. +function MOOSE_BRIDGE:_NavigationMenuEntry(params) + local group_name = type(params.group_id) == "string" + and string.match(params.group_id, "^GROUP:(.+)$") or nil + local config = self.PlayerTestMenuConfig + local entry = group_name and self.PlayerTestMenus and self.PlayerTestMenus[group_name] + if not config or config.mode ~= "navigation" or config.owner_id ~= params.owner_id + or not entry or entry.session_id ~= params.session_id + or entry.owner_id ~= params.owner_id or not self:_SafeCall(entry.group, "IsAlive") + or self:_SafeCall(entry.group, "GetID") ~= entry.group_id + or #self:_PlayerTestMenuSessions(group_name) == 0 then + error("Navigation menu session inactive") + end + return entry +end + +function MOOSE_BRIDGE:_NavigationMenuPayload(group_name, entry) + local opsgroup = self:_PlayerEventOpsGroup(group_name) + return {menu_id="navigation", scope="group", owner_id=entry.owner_id, + session_id=entry.session_id, group_id="GROUP:" .. group_name, + group_name=group_name, group_sessions=self:_PlayerTestMenuSessions(group_name), + opsgroup_id=opsgroup and ("OPSGROUP:" .. group_name) or nil} +end + +--- Resolve exactly one live player aircraft; multicrew seats may share a unit. +-- Never use a group's first unit or cached position as a telemetry fallback. +function MOOSE_BRIDGE:_FlightStatusReferenceUnit(entry) + local unit_name = nil + for _, session in ipairs(self:_PlayerTestMenuSessions(entry.group:GetName())) do + local name = session.unit_id and string.match(session.unit_id, "^UNIT:(.+)$") + if not name or (unit_name and unit_name ~= name) then + error("Flight status requires exactly one player aircraft per group") + end + unit_name = name + end + if not unit_name then error("No player aircraft available for flight status") end + local wrapper = _DATABASE and _DATABASE.UNITS and _DATABASE.UNITS[unit_name] + local unit = self:_SafeCall(wrapper, "GetDCSObject") + if not unit or not unit:isExist() then error("Flight status aircraft is unavailable") end + local group = unit:getGroup() + if not group or group:getID() ~= entry.group_id then + error("Flight status aircraft no longer belongs to this group") + end + return unit_name, unit +end + +local function flight_status_number(value) + if type(value) == "number" and value == value and value ~= math.huge and value ~= -math.huge then + return value + end + return nil +end + +local function flight_status_vector(value) + if type(value) == "table" and flight_status_number(value.x) + and flight_status_number(value.y) and flight_status_number(value.z) then + return {x=value.x, y=value.y, z=value.z} + end + return nil +end + +--- Read DCS world telemetry once; Python derives labeled flight-state quantities. +function MOOSE_BRIDGE:_GetPlayerFlightStatus(params) + local entry = self:_NavigationMenuEntry(params) + local name, unit = self:_FlightStatusReferenceUnit(entry) + local position = unit:getPosition() + local point = type(position) == "table" and flight_status_vector(position.p) + if not point then error("Flight status position is unavailable") end + local velocity_ok, velocity = pcall(function() return unit:getVelocity() end) + local terrain_ok, terrain = pcall(function() + return land.getHeight({x=point.x, y=point.z}) -- DCS land API uses Vec2. + end) + local north_ok, north = pcall(function() + local lat, lon = coord.LOtoLL(point) + if not flight_status_number(lat) or not flight_status_number(lon) or math.abs(lat) >= 89.999 then + return nil + end + -- Local geographic north tangent. Subtract endpoints to avoid inverse-map + -- round-trip offsets. Heading/track must not treat DCS grid north as TRUE. + local a = flight_status_vector(coord.LLtoLO(lat - 0.001, lon)) + local b = flight_status_vector(coord.LLtoLO(lat + 0.001, lon)) + if not a or not b then return nil end + return {x=b.x-a.x, y=0, z=b.z-a.z} + end) + return {owner_id=entry.owner_id, session_id=entry.session_id, + group_id="GROUP:" .. entry.group:GetName(), unit_id="UNIT:" .. name, + sample_time_s=flight_status_number(timer.getTime()), + altitude_msl_m=point.y, + terrain_elevation_m=terrain_ok and flight_status_number(terrain) or nil, + velocity_mps=velocity_ok and flight_status_vector(velocity) or nil, + forward=flight_status_vector(position.x), + true_north=north_ok and flight_status_vector(north) or nil} +end + +--- Group context, NOT the identity of the player who clicked the radio menu. +-- DCS/MOOSE group command callbacks only receive our bound arguments. +function MOOSE_BRIDGE:_PlayerTestMenuSessions(group_name) + local sessions = {} + for _, session in pairs(self.PlayerAircraftSessions or {}) do + if session.group_name == group_name then + sessions[#sessions + 1] = { + player_name=session.player_name, + unit_id=session.unit_name and ("UNIT:" .. session.unit_name) or nil, + } + end + end + table.sort(sessions, function(a, b) + return (a.player_name or a.unit_id or "") < (b.player_name or b.unit_id or "") + end) + return sessions +end + +--- Remove only this bridge's test tree, including stale MOOSE index entries. +function MOOSE_BRIDGE:_RemovePlayerTestMenu(group_name) + local entry = self.PlayerTestMenus and self.PlayerTestMenus[group_name] + if not entry then return end + self.PlayerTestMenus[group_name] = nil -- Invalidate callbacks before removal. + if entry.mode == "navigation" then + -- Use only this entry's overlay; other scripts' F10 drawings stay untouched. + local cleared, clear_err = pcall(function() self:_ClearDebugOverlay(entry.overlay_id) end) + if not cleared then self:_Log("Navigation overlay cleanup failed: " .. tostring(clear_err)) end + self:SendEvent("player.menu.closed", {menu_id="navigation", owner_id=entry.owner_id, + session_id=entry.session_id, group_id="GROUP:" .. group_name}) + end + local ok, err = pcall(function() + -- MENU_GROUP:Remove() checks IsAlive(). After despawn it can leave the + -- index intact, causing New() to reuse an obsolete GroupID on respawn. + local owned = {} + local function collect(menu) + owned[#owned + 1] = menu + for _, child in pairs(menu.Menus or {}) do collect(child) end + end + collect(entry.menu) + local removed, remove_err = pcall(function() entry.menu:Remove() end) + if not removed then self:_Log("MOOSE menu removal failed: " .. tostring(remove_err)) end + local index = MENU_INDEX and MENU_INDEX.Group[group_name] + for _, menu in ipairs(owned) do + if index and index.Menus[menu.Path] == menu then + missionCommands.removeItemForGroup(menu.GroupID, menu.MenuPath) + index.Menus[menu.Path] = nil + end + end + end) + if not ok then self:_Log("Failed to remove player test menu: " .. tostring(err)) end +end + +function MOOSE_BRIDGE:_ClearPlayerTestMenus() + local names = {} + for name, _ in pairs(self.PlayerTestMenus or {}) do names[#names + 1] = name end + for _, name in ipairs(names) do self:_RemovePlayerTestMenu(name) end +end + +--- Keep a single menu per occupied group; independent of FLIGHTGROUP creation. +function MOOSE_BRIDGE:_SyncPlayerTestMenu(group_name, group) + if not group_name then return end + local sessions = self:_PlayerTestMenuSessions(group_name) + local config = self.PlayerTestMenuConfig + if not config or #sessions == 0 then + self:_RemovePlayerTestMenu(group_name) + return + end + group = group or (_DATABASE and _DATABASE.GROUPS and _DATABASE.GROUPS[group_name]) + if not group or not self:_SafeCall(group, "IsAlive") then + self:_RemovePlayerTestMenu(group_name) + return + end + self.PlayerTestMenus = self.PlayerTestMenus or {} + local entry = self.PlayerTestMenus[group_name] + if entry and entry.group_id == group:GetID() then return end + self:_RemovePlayerTestMenu(group_name) + self.PlayerMenuSerial = (self.PlayerMenuSerial or 0) + 1 + entry = {group=group, group_id=group:GetID(), owner_id=config.owner_id, + mode=config.mode, session_id=tostring(self.PlayerMenuSerial), + overlay_id="navigation-menu-" .. tostring(self.PlayerMenuSerial)} + if config.mode == "navigation" then + entry.menu = MENU_GROUP:New(group, "Navigation") + else + entry.menu = MENU_GROUP:New(group, "MoosePyBridge Test") + end + self.PlayerTestMenus[group_name] = entry -- Also owns a partially built tree. + if config.mode == "navigation" then + local actions = {{"Show route", "route_show"}, {"Hide route", "route_hide"}, + {"Navigation status", "status"}, {"Flight status", "flight_status"}, + {"Enable hints", "hints_on"}, {"Disable hints", "hints_off"}} + for _, item in ipairs(actions) do + local action = item[2] -- One binding per callback, also on Lua 5.1. + MENU_GROUP_COMMAND:New(group, item[1], entry.menu, function() + self:_OnPlayerTestMenuSelected(group_name, entry, action) + end) + end + return + end + MENU_GROUP_COMMAND:New(group, "Show message", entry.menu, function() + self:_OnPlayerTestMenuSelected(group_name, entry, "message") + end) + MENU_GROUP_COMMAND:New(group, "Python console", entry.menu, function() + self:_OnPlayerTestMenuSelected(group_name, entry, "python_console") + end) +end + +function MOOSE_BRIDGE:_OnPlayerTestMenuSelected(group_name, entry, action) + local config = self.PlayerTestMenuConfig + if not config or config.owner_id ~= entry.owner_id + or not self.PlayerTestMenus or self.PlayerTestMenus[group_name] ~= entry then return end + local sessions = self:_PlayerTestMenuSessions(group_name) + if #sessions == 0 or not self:_SafeCall(entry.group, "IsAlive") + or self:_SafeCall(entry.group, "GetID") ~= entry.group_id then return end + if entry.mode == "navigation" then + if action ~= "route_show" and action ~= "route_hide" and action ~= "status" + and action ~= "flight_status" and action ~= "hints_on" and action ~= "hints_off" then return end + local payload = self:_NavigationMenuPayload(group_name, entry) + payload.action = action + self:SendEvent("player.menu.selected", payload) + elseif action == "message" then + MESSAGE:New("Menu test successful! Group: " .. group_name, 10, "MoosePyBridge") + :ToGroup(entry.group) + elseif action == "python_console" then + self:SendEvent("player.menu.selected", { + menu_id="player-menu-test", + action=action, + owner_id=entry.owner_id, + scope="group", + group_id="GROUP:" .. group_name, + group_name=group_name, + group_sessions=sessions, + }) + end +end + +--- Opt-in test, enabled by the VS Code client, never by default. +-- A new run replaces an abandoned test; an old client's cleanup cannot remove it. +function MOOSE_BRIDGE:_ConfigurePlayerTestMenus(params, mode) + if type(params.enabled) ~= "boolean" then error("enabled must be boolean") end + if type(params.owner_id) ~= "string" or #params.owner_id == 0 or #params.owner_id > 128 then + error("owner_id must be a non-empty string of at most 128 characters") + end + if params.enabled then + if not MENU_GROUP or not MENU_GROUP_COMMAND or not MESSAGE then + error("MOOSE MENU_GROUP, MENU_GROUP_COMMAND and MESSAGE are required") + end + self.PlayerTestMenuConfig = nil + self:_ClearPlayerTestMenus() + self.PlayerTestMenuConfig = {owner_id=params.owner_id, mode=mode or "test"} + local ok, err = pcall(function() + for _, session in pairs(self.PlayerAircraftSessions or {}) do + self:_SyncPlayerTestMenu(session.group_name) + end + end) + if not ok then + self.PlayerTestMenuConfig = nil + self:_ClearPlayerTestMenus() + error(err) + end + elseif self.PlayerTestMenuConfig and self.PlayerTestMenuConfig.owner_id == params.owner_id then + self.PlayerTestMenuConfig = nil + self:_ClearPlayerTestMenus() + end + local count = 0 + for group_name in pairs(self.PlayerTestMenus or {}) do count = count + 1 end + return {enabled=self.PlayerTestMenuConfig ~= nil, group_count=count} +end + +--- Find cached enter data when PlayerLeaveUnit omits player or wrapper fields. +function MOOSE_BRIDGE:_CachedPlayerAircraftSession(player_name, unit_name) + for key, session in pairs(self.PlayerAircraftSessions or {}) do + if (player_name and session.player_name == player_name) + or (not player_name and unit_name and session.unit_name == unit_name) then + return session, key + end + end + return nil, nil +end + +--- Announce a player entering an aircraft in dcs.log and to that DCS group. +function MOOSE_BRIDGE:_NotifyPlayerEnteredAircraft(session, group) + local player_name = session.player_name or "" + local unit_name = session.unit_name or "" + local aircraft_type = session.aircraft_type or "" + local group_name = session.group_name or "" + self:_Log(string.format( + "Player/client entered aircraft: player='%s', unit='%s', type='%s', group='%s'", + player_name, + unit_name, + aircraft_type, + group_name + )) + + if not MESSAGE then + self:_Log("Cannot display player-enter message: MOOSE MESSAGE is unavailable") + return self + end + if not group then + self:_Log("Cannot display player-enter message: group '" .. group_name .. "' is unavailable") + return self + end + + local text = string.format( + "Player %s entered aircraft slot %s (%s).", + player_name, + unit_name, + aircraft_type + ) + local ok, err = pcall(function() + MESSAGE:New(text, 10, "MoosePyBridge"):ToGroup(group) + end) + if not ok then + self:_Log("Failed to display player-enter message for group '" + .. group_name .. "': " .. tostring(err)) + end + return self +end + +--- Record a player leaving an aircraft after enriching the event from cache. +function MOOSE_BRIDGE:_LogPlayerLeftAircraft(session) + self:_Log(string.format( + "Player/client left aircraft: player='%s', unit='%s', type='%s', group='%s'", + session.player_name or "", + session.unit_name or "", + session.aircraft_type or "", + session.group_name or "" + )) + return self +end + +--- Normalize the player slot lifecycle for Python consumers. +function MOOSE_BRIDGE:_ForwardPlayerAircraftEvent(EventData, event_name, dcs_event_name, entering) + local ok, err = pcall(function() + if type(EventData) ~= "table" then error(dcs_event_name .. " event data is missing") end + + local player_name = EventData.IniPlayerName or EventData.PlayerName + local unit_name = EventData.IniUnitName or EventData.IniDCSUnitName + local cached, cache_key = self:_CachedPlayerAircraftSession(player_name, unit_name) + player_name = player_name or (cached and cached.player_name) + unit_name = unit_name or (cached and cached.unit_name) + local group_name = EventData.IniGroupName or EventData.IniDCSGroupName + or (cached and cached.group_name) + local aircraft_type = EventData.IniTypeName or (cached and cached.aircraft_type) + local coalition_name = self:_CoalitionToName(EventData.IniCoalition) + or (cached and cached.coalition) + if not player_name and not unit_name then + error(dcs_event_name .. " has neither player nor unit identity") + end + + local unit = EventData.IniUnit + if not unit and unit_name and _DATABASE and type(_DATABASE.UNITS) == "table" then + unit = _DATABASE.UNITS[unit_name] + end + local unit_item = nil + if unit and unit_name then + local snapshot_ok, value = pcall(function() return self:_BuildUnitSnapshotItem(unit_name, unit) end) + if snapshot_ok then unit_item = value end + end + + local group = EventData.IniGroup + if not group and group_name and _DATABASE and type(_DATABASE.GROUPS) == "table" then + group = _DATABASE.GROUPS[group_name] + end + local group_item = nil + if group and group_name then + local snapshot_ok, value = pcall(function() return self:_BuildGroupSnapshotItem(group_name, group) end) + if snapshot_ok then group_item = value end + end + + local opsgroup, opsgroup_source = self:_PlayerEventOpsGroup(group_name) + local opsgroup_item = nil + if opsgroup then + local snapshot_ok, value = pcall(function() + return self:_BuildOpsGroupSnapshotItem(group_name, opsgroup, opsgroup_source) + end) + if snapshot_ok then opsgroup_item = value end + end + + local session = { + player_name=player_name and tostring(player_name) or nil, + unit_name=unit_name and tostring(unit_name) or nil, + group_name=group_name and tostring(group_name) or nil, + aircraft_type=aircraft_type and tostring(aircraft_type) or nil, + coalition=coalition_name, + } + self.PlayerAircraftSessions = self.PlayerAircraftSessions or {} + self.PlayerAircraftLeaveTimes = self.PlayerAircraftLeaveTimes or {} + local lifecycle_key = session.player_name or (session.unit_name and ("UNIT:" .. session.unit_name)) + local lifecycle_time = tonumber(EventData.time) + or (timer and timer.getTime and timer.getTime()) or 0 + if entering then + local key = session.player_name or ("UNIT:" .. tostring(session.unit_name)) + if lifecycle_key then self.PlayerAircraftLeaveTimes[lifecycle_key] = nil end + self.PlayerAircraftSessions[key] = session + self:_NotifyPlayerEnteredAircraft(session, group) + else + local previous_leave = lifecycle_key and self.PlayerAircraftLeaveTimes[lifecycle_key] or nil + if previous_leave and math.abs(lifecycle_time - previous_leave) <= 1 then + self:_Log("Suppressed duplicate PlayerLeaveUnit for player='" + .. tostring(session.player_name or "") .. "', unit='" + .. tostring(session.unit_name or "") .. "'") + return + end + if lifecycle_key then self.PlayerAircraftLeaveTimes[lifecycle_key] = lifecycle_time end + self:_LogPlayerLeftAircraft(session) + if cache_key then self.PlayerAircraftSessions[cache_key] = nil end + end + + self:SendEvent(event_name, { + dcs_event_id=EventData.id, + dcs_event_name=dcs_event_name, + dcs_event_time=EventData.time, + player_name=session.player_name, + unit_id=session.unit_name and ("UNIT:" .. session.unit_name) or nil, + unit_name=session.unit_name, + group_id=session.group_name and ("GROUP:" .. session.group_name) or nil, + group_name=session.group_name, + opsgroup_id=opsgroup_item and opsgroup_item.object_id or nil, + aircraft_type=session.aircraft_type, + coalition=session.coalition, + unit=unit_item, + group=group_item, + opsgroup=opsgroup_item, + }) + -- Menu failures must not interrupt the established player lifecycle. + local menu_ok, menu_err = pcall(function() + if cached and cached.group_name ~= session.group_name then + self:_SyncPlayerTestMenu(cached.group_name) + end + self:_SyncPlayerTestMenu(session.group_name, group) + end) + if not menu_ok then + self:_RemovePlayerTestMenu(session.group_name) + self:_Log("Failed to update player test menu: " .. tostring(menu_err)) + end + end) + if not ok then + self:_Log("Failed to forward " .. tostring(dcs_event_name) .. " event: " .. tostring(err)) + end +end + +--- Finish an entry once, resolving FLIGHTGROUP/OPSGROUP at processing time. +function MOOSE_BRIDGE:_FlushPendingPlayerAircraftEnter(EventData) + if not self.PendingPlayerAircraftEnters or not self.PendingPlayerAircraftEnters[EventData] then return end + self.PendingPlayerAircraftEnters[EventData] = nil + self:_ForwardPlayerAircraftEvent( + EventData, + "player.aircraft.entered", + "MOOSE_PLAYER_ENTER_AIRCRAFT", + true + ) +end + +--- Allow mission handlers for the same event to create their FLIGHTGROUP first. +function MOOSE_BRIDGE:OnEventPlayerEnterAircraft(EventData) + if type(EventData) ~= "table" then + self:_Log("Cannot schedule PlayerEnterAircraft: event data is missing") + return + end + -- MOOSE may reuse the event table; preserve its fields and original time. + local pending = {} + for key, value in pairs(EventData) do pending[key] = value end + self.PendingPlayerAircraftEnters = self.PendingPlayerAircraftEnters or {} + -- BASE:ScheduleOnce reuses self.Scheduler and changes its MasterObject to + -- nil. That scheduler drives _Tick and must retain the bridge as its owner. + -- Keep this one-shot scheduler separate and alive until the entry is flushed. + self.PendingPlayerAircraftEnters[pending] = SCHEDULER:New(self, function(bridge) + bridge:_FlushPendingPlayerAircraftEnter(pending) + end, {}, 0.5) +end + +--- Forward DCS S_EVENT_PLAYER_LEAVE_UNIT and close the cached player session. +function MOOSE_BRIDGE:OnEventPlayerLeaveUnit(EventData) + -- A rapid exit must never be followed by a delayed, stale entry in Python. + -- Flush its pending entry first so Enter -> Leave ordering is preserved. + if type(EventData) == "table" then + local player_name = EventData.IniPlayerName or EventData.PlayerName + local unit_name = EventData.IniUnitName or EventData.IniDCSUnitName + for pending, _ in pairs(self.PendingPlayerAircraftEnters or {}) do + local pending_player = pending.IniPlayerName or pending.PlayerName + local pending_unit = pending.IniUnitName or pending.IniDCSUnitName + if (player_name and pending_player == player_name) + or (not player_name and unit_name and pending_unit == unit_name) then + self:_FlushPendingPlayerAircraftEnter(pending) + end + end + end + self:_ForwardPlayerAircraftEvent( + EventData, + "player.aircraft.left", + "S_EVENT_PLAYER_LEAVE_UNIT", + false + ) +end + --- Forward one DCS F10 map-marker event without interpreting its text. -- Marker commands remain a Python concern so the Lua bridge stays semantic -- and does not couple mission scripts to verification workflows. @@ -359,6 +989,10 @@ end -- 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) + self.PendingPlayerAircraftEnters = {} + self.PlayerTestMenuConfig = nil + self:_ClearPlayerTestMenus() + self.PlayerAircraftSessions = {} local ok, err = pcall(function() self:SendEvent("mission.ended", { dcs_event_id=EventData and EventData.id or nil,