From b38336fb6a40d5ec24edcfcfeab057a4f2897b96 Mon Sep 17 00:00:00 2001 From: iTracerFacer <134304944+iTracerFacer@users.noreply.github.com> Date: Wed, 5 Nov 2025 07:30:37 -0600 Subject: [PATCH] FAC laser code reservation and visibility. Coalition-level Admin/Help menus. Build confirmations and cooldowns (CTLD). --- Moose_CTLD_Pure/Moose_CTLD.lua | 61 +++++++++++++++- Moose_CTLD_Pure/Moose_CTLD_FAC.lua | 110 ++++++++++++++++++++++++++++- 2 files changed, 168 insertions(+), 3 deletions(-) diff --git a/Moose_CTLD_Pure/Moose_CTLD.lua b/Moose_CTLD_Pure/Moose_CTLD.lua index c1c4549..79e52e4 100644 --- a/Moose_CTLD_Pure/Moose_CTLD.lua +++ b/Moose_CTLD_Pure/Moose_CTLD.lua @@ -156,6 +156,11 @@ CTLD.Config = { CrateLifetime = 3600, -- seconds before crates auto-clean MessageDuration = 15, -- seconds for on-screen messages Debug = false, + -- Build safety + BuildConfirmEnabled = true, -- require a second confirmation within a short window before building + BuildConfirmWindowSeconds = 10, -- seconds allowed between first and second "Build Here" press + BuildCooldownEnabled = true, -- after a successful build, impose a cooldown before allowing another build by the same group + BuildCooldownSeconds = 60, -- seconds of cooldown after a successful build per group PickupZoneSmokeColor = trigger.smokeColor.Green, -- default smoke color when spawning crates at pickup zones RequirePickupZoneForCrateRequest = true, -- enforce that crate requests must be near a Supply (Pickup) Zone PickupZoneMaxDistance = 10000, -- meters; nearest pickup zone must be within this distance to allow a request @@ -297,6 +302,8 @@ CTLD._hoverState = {} -- [unitName] = { targetCrate=name, startTime=t } CTLD._unitLast = {} -- [unitName] = { x, z, t } CTLD._coachState = {} -- [unitName] = { lastKeyTimes = {key->time}, lastHint = "", phase = "", lastPhaseMsg = 0, target = crateName, holdStart = nil } CTLD._msgState = { } -- messaging throttle state: [scopeKey] = { lastKeyTimes = { key -> time } } +CTLD._buildConfirm = {} -- [groupName] = time of first build request (awaiting confirmation) +CTLD._buildCooldown = {} -- [groupName] = time of last successful build -- #endregion State @@ -727,9 +734,12 @@ end function CTLD:InitMenus() if self.Config.UseGroupMenus then self:WireBirthHandler() + -- Always provide a coalition-level Admin/Help menu for mission makers + self:InitCoalitionAdminMenu() else self.MenuRoot = MENU_COALITION:New(self.Side, 'CTLD') self:BuildCoalitionMenus(self.MenuRoot) + self:InitCoalitionAdminMenu() end end @@ -911,6 +921,31 @@ function CTLD:BuildCoalitionMenus(root) end) end end + +function CTLD:InitCoalitionAdminMenu() + if self.AdminMenu then return end + local root = MENU_COALITION:New(self.Side, 'CTLD Admin/Help') + MENU_COALITION_COMMAND:New(self.Side, 'Enable CTLD Debug Logging', root, function() + self.Config.Debug = true + env.info(string.format('[Moose_CTLD][%s] Debug ENABLED via Admin menu', tostring(self.Side))) + _msgCoalition(self.Side, 'CTLD Debug logging ENABLED', 8) + end) + MENU_COALITION_COMMAND:New(self.Side, 'Disable CTLD Debug Logging', root, function() + self.Config.Debug = false + env.info(string.format('[Moose_CTLD][%s] Debug DISABLED via Admin menu', tostring(self.Side))) + _msgCoalition(self.Side, 'CTLD Debug logging DISABLED', 8) + end) + MENU_COALITION_COMMAND:New(self.Side, 'Show CTLD Status (crates/zones)', root, function() + local crates = 0 + for _ in pairs(CTLD._crates) do crates = crates + 1 end + local msg = string.format('CTLD Status:\nActive crates: %d\nPickup zones: %d\nDrop zones: %d\nFOB zones: %d\nBuild Confirm: %s (%ds window)\nBuild Cooldown: %s (%ds)' + , crates, #(self.PickupZones or {}), #(self.DropZones or {}), #(self.FOBZones or {}) + , self.Config.BuildConfirmEnabled and 'ON' or 'OFF', self.Config.BuildConfirmWindowSeconds or 0 + , self.Config.BuildCooldownEnabled and 'ON' or 'OFF', self.Config.BuildCooldownSeconds or 0) + _msgCoalition(self.Side, msg, 20) + end) + self.AdminMenu = root +end -- #endregion Menus -- ========================= @@ -1069,6 +1104,29 @@ end function CTLD:BuildAtGroup(group) local unit = group:GetUnit(1) if not unit or not unit:IsAlive() then return end + -- Build cooldown/confirmation guardrails + local now = timer.getTime() + local gname = group:GetName() + if self.Config.BuildCooldownEnabled then + local last = CTLD._buildCooldown[gname] + if last and (now - last) < (self.Config.BuildCooldownSeconds or 60) then + local rem = math.max(0, math.ceil((self.Config.BuildCooldownSeconds or 60) - (now - last))) + _msgGroup(group, string.format('Build on cooldown. Try again in %ds.', rem)) + return + end + end + if self.Config.BuildConfirmEnabled then + local first = CTLD._buildConfirm[gname] + local win = self.Config.BuildConfirmWindowSeconds or 10 + if not first or (now - first) > win then + CTLD._buildConfirm[gname] = now + _msgGroup(group, string.format('Confirm build: select "Build Here" again within %ds to proceed.', win)) + return + else + -- within window; proceed and clear pending + CTLD._buildConfirm[gname] = nil + end + end local p = unit:GetPointVec3() local here = { x = p.x, z = p.z } local radius = self.Config.BuildRadius @@ -1091,7 +1149,6 @@ function CTLD:BuildAtGroup(group) end -- Include loaded crates carried by this group - local gname = group:GetName() local carried = CTLD._loadedCrates[gname] if self.Config.BuildRequiresGroundCrates ~= true then if carried and carried.byKey then @@ -1145,6 +1202,7 @@ function CTLD:BuildAtGroup(group) if g then for reqKey,qty in pairs(cat.requires) do consumeCrates(reqKey, qty) end _eventSend(self, nil, self.Side, 'build_success_coalition', { build = cat.description or recipeKey, player = _playerNameFromGroup(group) }) + if self.Config.BuildCooldownEnabled then CTLD._buildCooldown[gname] = now end return else _eventSend(self, group, nil, 'build_failed', { reason = 'DCS group spawn error' }) @@ -1169,6 +1227,7 @@ function CTLD:BuildAtGroup(group) if g then consumeCrates(key, cat.required or 1) _eventSend(self, nil, self.Side, 'build_success_coalition', { build = cat.description or key, player = _playerNameFromGroup(group) }) + if self.Config.BuildCooldownEnabled then CTLD._buildCooldown[gname] = now end return else _eventSend(self, group, nil, 'build_failed', { reason = 'DCS group spawn error' }) diff --git a/Moose_CTLD_Pure/Moose_CTLD_FAC.lua b/Moose_CTLD_Pure/Moose_CTLD_FAC.lua index 19847db..becbc6a 100644 --- a/Moose_CTLD_Pure/Moose_CTLD_FAC.lua +++ b/Moose_CTLD_Pure/Moose_CTLD_FAC.lua @@ -128,6 +128,12 @@ FAC._facPilotNames = {} -- dynamic add on Birth if name contains AFAC/RECON/RE FAC._reccePilotNames = {} FAC._artDirectNames = {} +-- Laser code reservation per coalition side: [side] = { [code] = unitName } +FAC._reservedCodes = {} + +-- Coalition-level admin/help menu handle per side +FAC._coalitionMenus = {} + FAC._ArtyTasked = {} -- [groupName] = { tasked=int, timeTasked=time, tgt=Unit|nil, requestor=string|nil } FAC._RECCETasked = {} -- [unitName] = 1 when busy @@ -287,6 +293,9 @@ function FAC:New(ctld, cfg) o._schedStatus = SCHEDULER:New(nil, function() o:_checkFacStatus() end, {}, 5, 1.0) o._schedAI = SCHEDULER:New(nil, function() o:_artyAICall() end, {}, 10, 30) + -- Coalition-level Admin/Help menu + o:_ensureCoalitionMenu() + return o end -- #endregion Construction @@ -435,6 +444,26 @@ function FAC:_ensureMenus() end end +function FAC:_ensureCoalitionMenu() + -- Create a coalition-level Admin/Help menu regardless of per-group menus + if self._coalitionMenus[self.Side] then return end + local root = MENU_COALITION:New(self.Side, 'FAC Admin/Help') + MENU_COALITION_COMMAND:New(self.Side, 'Show FAC Codes In Use', root, function() + self:_showCodesCoalition() + end) + MENU_COALITION_COMMAND:New(self.Side, 'Enable FAC Debug Logging', root, function() + self.Config.Debug = true + env.info(string.format('[FAC][%s] Debug ENABLED via Admin menu', tostring(self.Side))) + trigger.action.outTextForCoalition(self.Side, 'FAC Debug logging ENABLED', 8) + end) + MENU_COALITION_COMMAND:New(self.Side, 'Disable FAC Debug Logging', root, function() + self.Config.Debug = false + env.info(string.format('[FAC][%s] Debug DISABLED via Admin menu', tostring(self.Side))) + trigger.action.outTextForCoalition(self.Side, 'FAC Debug logging DISABLED', 8) + end) + self._coalitionMenus[self.Side] = root +end + function FAC:_buildGroupMenus(group) -- Build the entire FAC/RECCE menu tree for a MOOSE GROUP local root = MENU_GROUP:New(group, 'FAC/RECCE') @@ -574,6 +603,18 @@ function FAC:_posString(u) local p = u:getPosition().p local lat, lon = coord.LOtoLL(p) local dms = _llToDMS(lat, lon) + -- Append code summary across active FACs for situational awareness + local codes = self._reservedCodes[side] or {} + local any = false + local summary = '\nCodes in use:\n' + for _,code in ipairs(self.Config.FAC_laser_codes or {}) do + local owner = codes[tostring(code)] + if owner then + any = true + summary = summary .. string.format(' %s -> %s\n', tostring(code), self:_facName(owner)) + end + end + if any then msg = msg .. summary end local mgrs = _mgrsToString(coord.LLtoMGRS(lat, lon)) local altM = math.floor(p.y) local altF = math.floor(p.y*3.28084) @@ -586,7 +627,11 @@ function FAC:_setOnStation(group, on) local uname = u:GetName() _dbg(self, string.format('Action:SetOnStation unit=%s on=%s', uname, tostring(on and true or false))) -- init defaults - if not self._laserCodes[uname] then self._laserCodes[uname] = self.Config.FAC_laser_codes[1] end + if not self._laserCodes[uname] then + -- Assign a free code on first-time activation + local code = self:_assignFreeCode(u:GetCoalition(), uname) + self._laserCodes[uname] = code or (self.Config.FAC_laser_codes and self.Config.FAC_laser_codes[1]) or '1688' + end if not self._markerType[uname] then self._markerType[uname] = self.Config.MarkerDefault end if not self._facUnits[uname] then self._facUnits[uname] = { name = uname, side = u:GetCoalition() } end @@ -597,6 +642,7 @@ function FAC:_setOnStation(group, on) self:_cancelLase(uname) self._currentTargets[uname] = nil self._facUnits[uname] = nil + self:_releaseCode(u:GetCoalition(), uname) end self._facOnStation[uname] = on and true or nil @@ -609,7 +655,9 @@ function FAC:_setLaserCode(group, code) local u = group:GetUnit(1); if not u or not u:IsAlive() then return end local uname = u:GetName() _dbg(self, string.format('Action:SetLaserCode unit=%s code=%s', uname, tostring(code))) - self._laserCodes[uname] = tostring(code) + -- Enforce simple reservation: reassign if taken + local assigned = self:_reserveCode(u:GetCoalition(), uname, tostring(code)) + self._laserCodes[uname] = assigned if self._facOnStation[uname] then trigger.action.outTextForCoalition(u:GetCoalition(), string.format('[FAC "%s" on-station using CODE %s]', self:_facName(uname), self._laserCodes[uname]), 10) end @@ -821,6 +869,9 @@ end function FAC:_cleanupFac(uname) self:_cancelLase(uname) + -- release reserved code if any + local side = (self._facUnits[uname] and self._facUnits[uname].side) or self.Side + if side then self:_releaseCode(side, uname) end self._laserCodes[uname] = nil self._markerType[uname] = nil self._markerColor[uname] = nil @@ -1208,6 +1259,61 @@ function FAC:_markPoint(group, point, label) local txt = string.format('FAC: %s at %s', label or 'Contact', _llToDMS(lat,lon)) trigger.action.markToCoalition(id, txt, p3, self.Side, true) end + +-- #region Code reservation helpers +function FAC:_reserveCode(side, uname, code) + self._reservedCodes[side] = self._reservedCodes[side] or {} + local pool = self._reservedCodes[side] + code = tostring(code) + if pool[code] and pool[code] ~= uname then + -- Find a free alternative from configured list + local fallback = self:_assignFreeCode(side, uname) + if fallback then + -- Inform coalition about reassignment + trigger.action.outTextForCoalition(side, string.format('FAC %s requested code %s but it is in use by %s. Assigned %s instead.', self:_facName(uname), code, self:_facName(pool[code]), fallback), 10) + return fallback + end + -- No free code, keep requested (collision allowed with notice) + trigger.action.outTextForCoalition(side, string.format('FAC %s is sharing code %s with %s (no free codes).', self:_facName(uname), code, self:_facName(pool[code])), 10) + end + pool[code] = uname + return code +end + +function FAC:_assignFreeCode(side, uname) + self._reservedCodes[side] = self._reservedCodes[side] or {} + local pool = self._reservedCodes[side] + for _,c in ipairs(self.Config.FAC_laser_codes or {'1688'}) do + local key = tostring(c) + if not pool[key] or pool[key] == uname then + pool[key] = uname + return key + end + end + return nil +end + +function FAC:_releaseCode(side, uname) + local pool = self._reservedCodes[side] + if not pool then return end + for code,owner in pairs(pool) do + if owner == uname then pool[code] = nil end + end +end + +function FAC:_showCodesCoalition() + local side = self.Side + local pool = self._reservedCodes[side] or {} + local lines = {'FAC Codes In Use:\n'} + local any = false + for _,c in ipairs(self.Config.FAC_laser_codes or {'1688'}) do + local owner = pool[tostring(c)] + if owner then any = true; table.insert(lines, string.format(' %s -> %s', tostring(c), self:_facName(owner))) end + end + if not any then table.insert(lines, ' (none)') end + trigger.action.outTextForCoalition(side, table.concat(lines, '\n'), 15) +end +-- #endregion Code reservation helpers -- #endregion Mark helpers -- #region Export