PyBridge Navigation

This commit is contained in:
Frank
2026-08-31 13:57:50 +02:00
parent f5aef8edb9
commit 8cf47dc42d
3 changed files with 660 additions and 4 deletions
+7 -2
View File
@@ -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
+19 -2
View File
@@ -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()
@@ -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 "<unknown player>"
local unit_name = session.unit_name or "<unknown unit>"
local aircraft_type = session.aircraft_type or "<unknown aircraft>"
local group_name = session.group_name or "<unknown group>"
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 "<unknown player>",
session.unit_name or "<unknown unit>",
session.aircraft_type or "<unknown aircraft>",
session.group_name or "<unknown group>"
))
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 "<unknown player>") .. "', unit='"
.. tostring(session.unit_name or "<unknown unit>") .. "'")
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,