Mix any troop types

 Load multiple groups until capacity is full
 See running total: "Loaded 4 more troops (total: 8)"
 Can't exceed aircraft max (11 for Huey)
 Can't exceed weight limits
This commit is contained in:
iTracerFacer 2025-11-09 00:43:35 -06:00
parent 6b94905650
commit 66117b7324
2 changed files with 511 additions and 60 deletions

View File

@ -111,9 +111,15 @@ CTLD.Messages = {
troops_loaded = "Loaded {count} troops—ready to deploy.",
troops_unloaded = "Deployed {count} troops.",
troops_unloaded_coalition = "{player} deployed {count} troops.",
troops_fast_roped = "Fast-roped {count} troops into the field!",
troops_fast_roped_coalition = "{player} fast-roped {count} troops from {aircraft}!",
no_troops = "No troops onboard.",
troops_deploy_failed = "Deploy failed: {reason}.",
troop_pickup_zone_required = "Move inside a Supply Zone to load troops. Nearest zone is {zone_dist}, {zone_dist_u} away bearing {zone_brg}°.",
troop_load_must_land = "Must be on the ground to load troops. Land and reduce speed to < {max_speed} {speed_u}.",
troop_load_too_fast = "Ground speed too high for loading. Reduce to < {max_speed} {speed_u} (current: {current_speed} {speed_u}).",
troop_unload_altitude_too_high = "Too high for fast-rope deployment. Maximum: {max_agl} m AGL (current: {current_agl} m). Land or descend.",
troop_unload_altitude_too_low = "Too low for safe fast-rope. Minimum: {min_agl} m AGL (current: {current_agl} m). Climb or land.",
-- Coach & nav
vectors_to_crate = "Nearest crate {id}: bearing {brg}°, range {rng} {rng_u}.",
@ -183,41 +189,46 @@ CTLD.Config = {
-- Per-aircraft capacity limits (realistic cargo/troop capacities)
-- Set maxCrates = 0 and maxTroops = 0 for attack helicopters with no cargo capability
-- If an aircraft type is not listed here, it will use DefaultCapacity values
-- maxWeightKg: optional weight capacity in kilograms (if omitted, only count limits apply)
-- requireGround: optional override for ground requirement (true = must land, false = can load in hover/flight)
-- maxGroundSpeed: optional override for max ground speed during loading (m/s)
AircraftCapacities = {
-- Small/Light Helicopters (very limited capacity)
['SA342M'] = { maxCrates = 1, maxTroops = 3 }, -- Gazelle - tiny observation/scout helo
['SA342L'] = { maxCrates = 1, maxTroops = 3 },
['SA342Minigun'] = { maxCrates = 1, maxTroops = 3 },
['GazelleAI'] = { maxCrates = 1, maxTroops = 3 },
['SA342M'] = { maxCrates = 1, maxTroops = 3, maxWeightKg = 400 }, -- Gazelle - tiny observation/scout helo
['SA342L'] = { maxCrates = 1, maxTroops = 3, maxWeightKg = 400 },
['SA342Minigun'] = { maxCrates = 1, maxTroops = 3, maxWeightKg = 400 },
['GazelleAI'] = { maxCrates = 1, maxTroops = 3, maxWeightKg = 400 },
-- Attack Helicopters (no cargo capacity - combat only)
['Ka-50'] = { maxCrates = 0, maxTroops = 0 }, -- Black Shark - single seat attack
['Ka-50_3'] = { maxCrates = 0, maxTroops = 0 }, -- Black Shark 3
['AH-64D_BLK_II'] = { maxCrates = 0, maxTroops = 0 }, -- Apache - attack/recon only
['Mi-24P'] = { maxCrates = 2, maxTroops = 8 }, -- Hind - attack helo but has small troop bay
['Ka-50'] = { maxCrates = 0, maxTroops = 0, maxWeightKg = 0 }, -- Black Shark - single seat attack
['Ka-50_3'] = { maxCrates = 0, maxTroops = 0, maxWeightKg = 0 }, -- Black Shark 3
['AH-64D_BLK_II'] = { maxCrates = 0, maxTroops = 0, maxWeightKg = 0 }, -- Apache - attack/recon only
['Mi-24P'] = { maxCrates = 2, maxTroops = 8, maxWeightKg = 1000 }, -- Hind - attack helo but has small troop bay
-- Light Utility Helicopters (moderate capacity)
['UH-1H'] = { maxCrates = 3, maxTroops = 11 }, -- Huey - classic light transport
['UH-1H'] = { maxCrates = 3, maxTroops = 11, maxWeightKg = 1800 }, -- Huey - classic light transport
-- Medium Transport Helicopters (good capacity)
['Mi-8MTV2'] = { maxCrates = 5, maxTroops = 24 }, -- Hip - Russian medium transport
['Mi-17'] = { maxCrates = 5, maxTroops = 24 }, -- Hip variant
['UH-60L'] = { maxCrates = 4, maxTroops = 11 }, -- Black Hawk - medium utility
['Mi-8MTV2'] = { maxCrates = 5, maxTroops = 24, maxWeightKg = 4000 }, -- Hip - Russian medium transport
['Mi-17'] = { maxCrates = 5, maxTroops = 24, maxWeightKg = 4000 }, -- Hip variant
['UH-60L'] = { maxCrates = 4, maxTroops = 11, maxWeightKg = 4000 }, -- Black Hawk - medium utility
-- Heavy Lift Helicopters (maximum capacity)
['CH-47Fbl1'] = { maxCrates = 10, maxTroops = 33 }, -- Chinook - heavy lift beast
['CH-47F'] = { maxCrates = 10, maxTroops = 33 }, -- Chinook variant
['CH-47Fbl1'] = { maxCrates = 10, maxTroops = 33, maxWeightKg = 12000 }, -- Chinook - heavy lift beast
['CH-47F'] = { maxCrates = 10, maxTroops = 33, maxWeightKg = 12000 }, -- Chinook variant
-- Fixed Wing Transport (limited capacity)
['C-130'] = { maxCrates = 20, maxTroops = 92 }, -- C-130 Hercules - tactical airlifter
['C-17A'] = { maxCrates = 30, maxTroops = 150 }, -- C-17 Globemaster III - strategic airlifter
-- NOTE: C-130 has requireGround configurable - set to false if you want to allow in-flight loading (unrealistic but flexible)
['C-130'] = { maxCrates = 20, maxTroops = 92, maxWeightKg = 20000, requireGround = true, maxGroundSpeed = 1.0 }, -- C-130 Hercules - tactical airlifter (must be fully stopped)
['C-17A'] = { maxCrates = 30, maxTroops = 150, maxWeightKg = 77500, requireGround = true, maxGroundSpeed = 1.0 }, -- C-17 Globemaster III - strategic airlifter
},
-- Default capacities for aircraft not listed in AircraftCapacities table
-- Used as fallback for any transport aircraft without specific limits defined
DefaultCapacity = {
maxCrates = 4, -- reasonable middle ground
maxTroops = 12, -- moderate squad size
maxCrates = 4, -- reasonable middle ground
maxTroops = 12, -- moderate squad size
maxWeightKg = 2000, -- default weight capacity in kg (omit to disable weight modeling)
},
UseGroupMenus = true, -- if true, F10 menus per player group; otherwise coalition-wide
@ -242,6 +253,16 @@ CTLD.Config = {
CrateLifetime = 3600, -- seconds before crates auto-clean up; 0 = disable
MessageDuration = 15, -- seconds for on-screen messages
Debug = false,
-- Ground requirements for loading (realistic behavior)
RequireGroundForTroopLoad = true, -- if true, must be landed to load troops (prevents loading while hovering)
RequireGroundForVehicleLoad = true, -- if true, must be landed to load vehicles (C-130/large transports)
MaxGroundSpeedForLoading = 2.0, -- meters/second: max ground speed allowed for loading (prevents loading while taxiing fast; ~4 knots)
-- Fast-rope deployment (allows troop unload while hovering at safe altitude)
EnableFastRope = true, -- if true, troops can fast-rope from hovering helicopters
FastRopeMaxHeight = 20, -- meters AGL: maximum altitude for fast-rope deployment
FastRopeMinHeight = 5, -- meters AGL: minimum altitude for fast-rope deployment (too low = collision risk)
-- Build safety
BuildConfirmEnabled = false, -- require a second confirmation within a short window before building
BuildConfirmWindowSeconds = 30, -- seconds allowed between first and second "Build Here" press
@ -477,8 +498,9 @@ CTLD.MEDEVAC = {
-- Internal state tables
CTLD._instances = CTLD._instances or {}
CTLD._crates = {} -- [crateName] = { key, zone, side, spawnTime, point }
CTLD._troopsLoaded = {} -- [groupName] = { count, typeKey }
CTLD._loadedCrates = {} -- [groupName] = { total=n, byKey = { key -> count } }
CTLD._troopsLoaded = {} -- [groupName] = { count, typeKey, weightKg }
CTLD._loadedCrates = {} -- [groupName] = { total=n, totalWeightKg=w, byKey = { key -> count } }
CTLD._deployedTroops = {} -- [groupName] = { typeKey, count, side, spawnTime, point, weightKg }
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 }
@ -544,13 +566,14 @@ local function _getUnitType(unit)
end
-- Get aircraft capacity limits for crates and troops
-- Returns { maxCrates, maxTroops } for the given unit
-- Returns { maxCrates, maxTroops, maxWeightKg } for the given unit
-- Falls back to DefaultCapacity if aircraft type not specifically configured
local function _getAircraftCapacity(unit)
if not unit then
return {
maxCrates = CTLD.Config.DefaultCapacity.maxCrates or 4,
maxTroops = CTLD.Config.DefaultCapacity.maxTroops or 12
maxTroops = CTLD.Config.DefaultCapacity.maxTroops or 12,
maxWeightKg = CTLD.Config.DefaultCapacity.maxWeightKg or 2000
}
end
@ -561,7 +584,8 @@ local function _getAircraftCapacity(unit)
if specific then
return {
maxCrates = specific.maxCrates or 0,
maxTroops = specific.maxTroops or 0
maxTroops = specific.maxTroops or 0,
maxWeightKg = specific.maxWeightKg or 0
}
end
@ -569,10 +593,44 @@ local function _getAircraftCapacity(unit)
local defaults = CTLD.Config.DefaultCapacity or {}
return {
maxCrates = defaults.maxCrates or 4,
maxTroops = defaults.maxTroops or 12
maxTroops = defaults.maxTroops or 12,
maxWeightKg = defaults.maxWeightKg or 2000
}
end
-- Check if a unit is in the air (flying/hovering, not landed)
-- Based on original CTLD logic: uses DCS InAir() API plus velocity threshold
-- Returns: true if airborne, false if landed/grounded
local function _isUnitInAir(unit)
if not unit then return false end
-- First check: DCS API InAir() - if it says we're on ground, trust it
if not unit:InAir() then
return false
end
-- Second check: velocity threshold (handles edge cases where InAir() is true but we're stationary on ground)
-- Less than 0.05 m/s (~0.1 knots) = essentially stopped = consider landed
-- NOTE: AI can hold perfect hover, so only apply this check for player-controlled units
local vel = unit:GetVelocity()
if vel and unit:GetPlayerName() then
local groundSpeed = math.sqrt(vel.x * vel.x + vel.z * vel.z) -- horizontal speed in m/s
if groundSpeed < 0.05 then
return false -- stopped on ground
end
end
return true -- airborne
end
-- Get ground speed in m/s for a unit
local function _getGroundSpeed(unit)
if not unit then return 0 end
local vel = unit:GetVelocity()
if not vel then return 0 end
return math.sqrt(vel.x * vel.x + vel.z * vel.z)
end
local function _nearestZonePoint(unit, list)
if not unit or not unit:IsAlive() then return nil end
-- Get unit position using DCS API to avoid dependency on MOOSE point methods
@ -693,6 +751,37 @@ local function _vec3FromUnit(unit)
return { x = p.x, y = p.y, z = p.z }
end
-- Update DCS internal cargo weight based on loaded crates and troops
-- This affects aircraft performance (hover, fuel consumption, speed, etc.)
local function _updateCargoWeight(group)
if not group then return end
local unit = group:GetUnit(1)
if not unit or not unit:IsAlive() then return end
local gname = group:GetName()
local totalWeight = 0
-- Add weight from loaded crates
local crateData = CTLD._loadedCrates[gname]
if crateData and crateData.totalWeightKg then
totalWeight = totalWeight + crateData.totalWeightKg
end
-- Add weight from loaded troops
local troopData = CTLD._troopsLoaded[gname]
if troopData and troopData.weightKg then
totalWeight = totalWeight + troopData.weightKg
end
-- Call DCS API to set internal cargo weight (affects flight model)
local unitName = unit:GetName()
if unitName and trigger and trigger.action and trigger.action.setUnitInternalCargo then
pcall(function()
trigger.action.setUnitInternalCargo(unitName, totalWeight)
end)
end
end
-- Unique id generator for map markups (lines/circles/text)
local function _nextMarkupId()
CTLD._NextMarkupId = (CTLD._NextMarkupId or 10000) + 1
@ -1559,6 +1648,11 @@ function CTLD:New(cfg)
o:CleanupCrates()
end, {}, 60, 60)
-- Periodic cleanup for deployed troops (remove dead/missing groups)
o.TroopCleanupSched = SCHEDULER:New(nil, function()
o:CleanupDeployedTroops()
end, {}, 30, 30)
-- Optional: auto-build FOBs inside FOB zones when crates present
if o.Config.AutoBuildFOBInZones then
o.AutoFOBSched = SCHEDULER:New(nil, function()
@ -3518,6 +3612,21 @@ function CTLD:CleanupCrates()
end
end
end
function CTLD:CleanupDeployedTroops()
-- Remove any deployed troop groups that are dead or no longer exist
for troopGroupName, troopMeta in pairs(CTLD._deployedTroops) do
if troopMeta.side == self.Side then
local troopGroup = GROUP:FindByName(troopGroupName)
if not troopGroup or not troopGroup:IsAlive() then
CTLD._deployedTroops[troopGroupName] = nil
if self.Config.Debug then
env.info('[CTLD] Cleaned up deployed troop group: '..troopGroupName)
end
end
end
end
end
-- #endregion Crates
-- =========================
@ -3782,12 +3891,26 @@ end
-- Loaded crate management
-- =========================
-- #region Loaded crate management
-- Update DCS internal cargo weight for a group
function CTLD:_updateCargoWeight(group)
_updateCargoWeight(group)
end
function CTLD:_addLoadedCrate(group, crateKey)
local gname = group:GetName()
CTLD._loadedCrates[gname] = CTLD._loadedCrates[gname] or { total = 0, byKey = {} }
CTLD._loadedCrates[gname] = CTLD._loadedCrates[gname] or { total = 0, totalWeightKg = 0, byKey = {} }
local lc = CTLD._loadedCrates[gname]
lc.total = lc.total + 1
lc.byKey[crateKey] = (lc.byKey[crateKey] or 0) + 1
-- Add weight from catalog
local cat = self.Config.CrateCatalog[crateKey]
local crateWeight = (cat and cat.weightKg) or 0
lc.totalWeightKg = (lc.totalWeightKg or 0) + crateWeight
-- Update DCS internal cargo weight
self:_updateCargoWeight(group)
end
function CTLD:DropLoadedCrates(group, howMany)
@ -3828,20 +3951,26 @@ function CTLD:DropLoadedCrates(group, howMany)
for k,count in pairs(DeepCopy(lc.byKey)) do
if toDrop <= 0 then break end
local dropNow = math.min(count, toDrop)
local cat = self.Config.CrateCatalog[k]
local crateWeight = (cat and cat.weightKg) or 0
for i=1,dropNow do
local cname = string.format('CTLD_CRATE_%s_%d', k, math.random(100000,999999))
local cat = self.Config.CrateCatalog[k]
_spawnStaticCargo(self.Side, dropPt, (cat and cat.dcsCargoType) or 'uh1h_cargo', cname)
CTLD._crates[cname] = { key = k, side = self.Side, spawnTime = timer.getTime(), point = { x = dropPt.x, z = dropPt.z } }
lc.byKey[k] = lc.byKey[k] - 1
if lc.byKey[k] <= 0 then lc.byKey[k] = nil end
lc.total = lc.total - 1
lc.totalWeightKg = (lc.totalWeightKg or 0) - crateWeight
toDrop = toDrop - 1
if toDrop <= 0 then break end
end
end
local actualDropped = initialTotal - (lc.total or 0)
_eventSend(self, group, nil, 'dropped_crates', { count = actualDropped })
-- Update DCS internal cargo weight after dropping
self:_updateCargoWeight(group)
-- Reiterate timeout after drop completes (players may miss the initial warning)
if lifeSec > 0 then
local mins = math.floor((lifeSec + 30) / 60)
@ -3992,6 +4121,7 @@ function CTLD:ScanHoverPickup()
-- find nearest crate within search distance
local bestName, bestMeta, bestd
local bestType = 'crate' -- Track whether we found a crate or troops
local maxd = coachCfg.autoPickupDistance or 25
for name,meta in pairs(CTLD._crates) do
if meta.side == self.Side then
@ -4000,6 +4130,31 @@ function CTLD:ScanHoverPickup()
local d = math.sqrt(dx*dx + dz*dz)
if d <= maxd and ((not bestd) or d < bestd) then
bestName, bestMeta, bestd = name, meta, d
bestType = 'crate'
end
end
end
-- Also scan for deployed troop groups to pick up
for troopGroupName, troopMeta in pairs(CTLD._deployedTroops) do
if troopMeta.side == self.Side then
local troopGroup = GROUP:FindByName(troopGroupName)
-- Only allow pickup if group exists and is alive
if troopGroup and troopGroup:IsAlive() then
local troopPos = troopGroup:GetCoordinate()
if troopPos then
local tp = troopPos:GetVec3()
local dx = (tp.x - p3.x)
local dz = (tp.z - p3.z)
local d = math.sqrt(dx*dx + dz*dz)
if d <= maxd and ((not bestd) or d < bestd) then
bestName, bestMeta, bestd = troopGroupName, troopMeta, d
bestType = 'troops'
end
end
else
-- Group doesn't exist or is dead, remove from tracking
CTLD._deployedTroops[troopGroupName] = nil
end
end
end
@ -4090,36 +4245,134 @@ function CTLD:ScanHoverPickup()
if withinRadius then
local carried = CTLD._loadedCrates[gname]
local total = carried and carried.total or 0
local currentWeight = carried and carried.totalWeightKg or 0
-- Get aircraft-specific capacity instead of global setting
local capacity = _getAircraftCapacity(unit)
local maxCrates = capacity.maxCrates
if total < maxCrates then
local maxTroops = capacity.maxTroops
local maxWeight = capacity.maxWeightKg or 0
-- Calculate weight and check capacity based on type
local itemWeight = 0
local countOK = false
local weightOK = false
if bestType == 'crate' then
-- Picking up a crate
itemWeight = (bestMeta and self.Config.CrateCatalog[bestMeta.key] and self.Config.CrateCatalog[bestMeta.key].weightKg) or 0
local wouldBeWeight = currentWeight + itemWeight
countOK = (total < maxCrates)
weightOK = (maxWeight <= 0) or (wouldBeWeight <= maxWeight)
elseif bestType == 'troops' then
-- Picking up troops - check if we can ADD them to existing load
itemWeight = bestMeta.weightKg or 0
local wouldBeWeight = currentWeight + itemWeight
local troopCount = bestMeta.count or 0
-- Check if we already have troops loaded - if so, check if we can add more
local currentTroops = CTLD._troopsLoaded[gname]
local currentTroopCount = currentTroops and currentTroops.count or 0
local totalTroopCount = currentTroopCount + troopCount
-- Check total capacity (allow mixing different troop types)
countOK = (totalTroopCount <= maxTroops)
weightOK = (maxWeight <= 0) or (wouldBeWeight <= maxWeight)
-- Provide feedback if capacity exceeded
if not countOK then
local hs = CTLD._hoverState[uname]
if not hs or hs.messageShown ~= true then
_msgGroup(group, string.format('Troop capacity exceeded! Current: %d, Adding: %d, Max: %d',
currentTroopCount, troopCount, maxTroops))
if not hs then
CTLD._hoverState[uname] = { messageShown = true }
else
hs.messageShown = true
end
end
end
end
-- Check both count AND weight limits
if countOK and weightOK then
local hs = CTLD._hoverState[uname]
if not hs or hs.targetCrate ~= bestName then
CTLD._hoverState[uname] = { targetCrate = bestName, startTime = now }
if not hs or hs.targetCrate ~= bestName or hs.targetType ~= bestType then
CTLD._hoverState[uname] = { targetCrate = bestName, targetType = bestType, startTime = now }
if coachEnabled then _coachSend(self, group, uname, 'coach_hold', {}, false) end
else
-- stability hold timer
local holdNeeded = coachCfg.thresholds.stabilityHold or 1.8
if (now - hs.startTime) >= holdNeeded then
-- load it
local obj = StaticObject.getByName(bestName)
if obj then obj:destroy() end
_cleanupCrateSmoke(bestName) -- Clean up smoke refresh schedule
CTLD._crates[bestName] = nil
self:_addLoadedCrate(group, bestMeta.key)
if coachEnabled then
_coachSend(self, group, uname, 'coach_loaded', {}, false)
else
_msgGroup(group, string.format('Loaded %s crate', tostring(bestMeta.key)))
if bestType == 'crate' then
local obj = StaticObject.getByName(bestName)
if obj then obj:destroy() end
_cleanupCrateSmoke(bestName) -- Clean up smoke refresh schedule
CTLD._crates[bestName] = nil
self:_addLoadedCrate(group, bestMeta.key)
if coachEnabled then
_coachSend(self, group, uname, 'coach_loaded', {}, false)
else
_msgGroup(group, string.format('Loaded %s crate', tostring(bestMeta.key)))
end
elseif bestType == 'troops' then
-- Pick up the troop group
local troopGroup = GROUP:FindByName(bestName)
if troopGroup then
troopGroup:Destroy()
end
CTLD._deployedTroops[bestName] = nil
-- ADD to existing troops if any, don't overwrite
local currentTroops = CTLD._troopsLoaded[gname]
if currentTroops then
-- Add to existing load (supports mixing types)
local troopTypes = currentTroops.troopTypes or { { typeKey = currentTroops.typeKey, count = currentTroops.count } }
table.insert(troopTypes, { typeKey = bestMeta.typeKey, count = bestMeta.count })
CTLD._troopsLoaded[gname] = {
count = currentTroops.count + bestMeta.count,
typeKey = 'Mixed', -- Indicate mixed types
troopTypes = troopTypes, -- Store individual type details
weightKg = currentTroops.weightKg + bestMeta.weightKg
}
_msgGroup(group, string.format('Loaded %d more troops (total: %d)', bestMeta.count, CTLD._troopsLoaded[gname].count))
else
-- First load
CTLD._troopsLoaded[gname] = {
count = bestMeta.count,
typeKey = bestMeta.typeKey,
troopTypes = { { typeKey = bestMeta.typeKey, count = bestMeta.count } },
weightKg = bestMeta.weightKg
}
if coachEnabled then
_msgGroup(group, string.format('Loaded %d troops', bestMeta.count))
else
_msgGroup(group, string.format('Loaded %d troops', bestMeta.count))
end
end
-- Update cargo weight
self:_updateCargoWeight(group)
end
CTLD._hoverState[uname] = nil
end
end
else
-- Aircraft at capacity - notify player
-- Aircraft at capacity - notify player with weight/count info
local aircraftType = _getUnitType(unit) or 'aircraft'
_eventSend(self, group, nil, 'crate_aircraft_capacity', { current = total, max = maxCrates, aircraft = aircraftType })
if not weightOK then
-- Weight limit exceeded
_msgGroup(group, string.format('Weight capacity reached! Current: %dkg, Item: %dkg, Max: %dkg for %s',
math.floor(currentWeight), math.floor(itemWeight), math.floor(maxWeight), aircraftType))
elseif bestType == 'crate' then
-- Count limit exceeded for crates
_eventSend(self, group, nil, 'crate_aircraft_capacity', { current = total, max = maxCrates, aircraft = aircraftType })
elseif bestType == 'troops' then
-- Count limit exceeded for troops
_eventSend(self, group, nil, 'troop_aircraft_capacity', { count = bestMeta.count or 0, max = maxTroops, aircraft = aircraftType })
end
CTLD._hoverState[uname] = nil
end
else
@ -4158,6 +4411,43 @@ function CTLD:LoadTroops(group, opts)
end
end
-- Ground requirement check for troop loading (realistic behavior)
if self.Config.RequireGroundForTroopLoad then
local unitType = _getUnitType(unit)
local capacities = self.Config.AircraftCapacities or {}
local specific = capacities[unitType]
-- Check per-aircraft override first, then fall back to global config
local requireGround = (specific and specific.requireGround ~= nil) and specific.requireGround or true
if requireGround then
-- Must be on the ground
if _isUnitInAir(unit) then
local isMetric = _getPlayerIsMetric(unit)
local maxSpeed = (specific and specific.maxGroundSpeed) or self.Config.MaxGroundSpeedForLoading or 2.0
local speedVal, speedUnit = _fmtSpeed(maxSpeed, isMetric)
_eventSend(self, group, nil, 'troop_load_must_land', { max_speed = speedVal, speed_u = speedUnit })
return
end
-- Check ground speed (must not be taxiing too fast)
local groundSpeed = _getGroundSpeed(unit)
local maxSpeed = (specific and specific.maxGroundSpeed) or self.Config.MaxGroundSpeedForLoading or 2.0
if groundSpeed > maxSpeed then
local isMetric = _getPlayerIsMetric(unit)
local currentVal, currentUnit = _fmtSpeed(groundSpeed, isMetric)
local maxVal, maxUnit = _fmtSpeed(maxSpeed, isMetric)
_eventSend(self, group, nil, 'troop_load_too_fast', {
current_speed = currentVal,
max_speed = maxVal,
speed_u = maxUnit
})
return
end
end
end
-- Enforce pickup zone requirement for troop loading (inside zone)
if self.Config.RequirePickupZoneForTroopLoad then
local hasPickupZones = (self.PickupZones and #self.PickupZones > 0) or (self.Config.Zones and self.Config.Zones.PickupZones and #self.Config.Zones.PickupZones > 0)
@ -4215,24 +4505,77 @@ function CTLD:LoadTroops(group, opts)
or (self.Config.Troops and self.Config.Troops.DefaultType)
or 'AS'
local unitsList, label = self:_resolveTroopUnits(requestedType)
local troopDef = (self.Config.Troops and self.Config.Troops.TroopTypes and self.Config.Troops.TroopTypes[requestedType]) or nil
-- Check if we already have troops (allow mixing different types now)
local currentTroops = CTLD._troopsLoaded[gname]
-- Check aircraft capacity for troops
local capacity = _getAircraftCapacity(unit)
local maxTroops = capacity.maxTroops
local maxWeight = capacity.maxWeightKg or 0
local troopCount = #unitsList
if troopCount > maxTroops then
-- Aircraft cannot carry this many troops
-- Calculate troop weight from catalog
local troopWeight = 0
if troopDef and troopDef.weightKg then
troopWeight = troopDef.weightKg
elseif troopCount > 0 then
-- Fallback: estimate 100kg per soldier if no weight defined
troopWeight = troopCount * 100
end
-- Check current cargo weight and troop count
local carried = CTLD._loadedCrates[gname]
local currentWeight = carried and carried.totalWeightKg or 0
local currentTroopCount = currentTroops and currentTroops.count or 0
local totalTroopCount = currentTroopCount + troopCount
local wouldBeWeight = currentWeight + troopWeight
-- Check total troop count limit
if totalTroopCount > maxTroops then
-- Aircraft cannot carry this many troops total
local aircraftType = _getUnitType(unit) or 'aircraft'
_eventSend(self, group, nil, 'troop_aircraft_capacity', { count = troopCount, max = maxTroops, aircraft = aircraftType })
_msgGroup(group, string.format('Troop capacity exceeded! Current: %d, Adding: %d, Max: %d for %s',
currentTroopCount, troopCount, maxTroops, aircraftType))
return
end
CTLD._troopsLoaded[gname] = {
count = #unitsList,
typeKey = requestedType,
}
_eventSend(self, group, nil, 'troops_loaded', { count = #unitsList })
-- Check weight limit (if enabled)
if maxWeight > 0 and wouldBeWeight > maxWeight then
-- Weight capacity exceeded
local aircraftType = _getUnitType(unit) or 'aircraft'
_msgGroup(group, string.format('Weight capacity exceeded! Current: %dkg, Troops: %dkg, Max: %dkg for %s',
math.floor(currentWeight), math.floor(troopWeight), math.floor(maxWeight), aircraftType))
return
end
-- ADD to existing troops or create new entry
if currentTroops then
-- Add to existing load (supports mixing types)
local troopTypes = currentTroops.troopTypes or { { typeKey = currentTroops.typeKey, count = currentTroops.count } }
table.insert(troopTypes, { typeKey = requestedType, count = troopCount })
CTLD._troopsLoaded[gname] = {
count = totalTroopCount,
typeKey = 'Mixed', -- Indicate mixed types
troopTypes = troopTypes, -- Store individual type details
weightKg = currentTroops.weightKg + troopWeight,
}
_eventSend(self, group, nil, 'troops_loaded', { count = totalTroopCount })
_msgGroup(group, string.format('Loaded %d more troops (total: %d)', troopCount, totalTroopCount))
else
CTLD._troopsLoaded[gname] = {
count = troopCount,
typeKey = requestedType,
troopTypes = { { typeKey = requestedType, count = troopCount } },
weightKg = troopWeight,
}
_eventSend(self, group, nil, 'troops_loaded', { count = troopCount })
end
-- Update DCS internal cargo weight
self:_updateCargoWeight(group)
end
function CTLD:UnloadTroops(group, opts)
@ -4249,6 +4592,52 @@ function CTLD:UnloadTroops(group, opts)
if medevacDelivered then
-- Crew delivered to MASH, clear troops and return
CTLD._troopsLoaded[gname] = nil
-- Update DCS internal cargo weight after delivery
self:_updateCargoWeight(group)
return
end
end
-- Determine if unit is in the air and check for fast-rope capability
local isInAir = _isUnitInAir(unit)
local canFastRope = false
local isFastRope = false
if isInAir then
-- Unit is airborne - check if fast-rope is enabled and if altitude is safe
if self.Config.EnableFastRope then
local p3 = unit:GetPointVec3()
local ground = land and land.getHeight and land.getHeight({x = p3.x, y = p3.z}) or 0
local agl = p3.y - ground
local maxFastRopeAGL = self.Config.FastRopeMaxHeight or 20
local minFastRopeAGL = self.Config.FastRopeMinHeight or 5
if agl > maxFastRopeAGL then
-- Too high for fast-rope
local isMetric = _getPlayerIsMetric(unit)
local aglDisplay = _fmtAGL(agl, isMetric)
_eventSend(self, group, nil, 'troop_unload_altitude_too_high', {
max_agl = math.floor(maxFastRopeAGL),
current_agl = math.floor(agl)
})
return
elseif agl < minFastRopeAGL then
-- Too low for safe fast-rope
_eventSend(self, group, nil, 'troop_unload_altitude_too_low', {
min_agl = math.floor(minFastRopeAGL),
current_agl = math.floor(agl)
})
return
else
-- Within safe fast-rope window
canFastRope = true
isFastRope = true
end
else
-- Fast-rope disabled - must land
_msgGroup(group, "Must land to deploy troops. Fast-rope is disabled.", 10)
return
end
end
@ -4265,6 +4654,7 @@ function CTLD:UnloadTroops(group, opts)
return
end
end
local p = unit:GetPointVec3()
local here = { x = p.x, z = p.z }
local hdgRad, _ = _headingRadDeg(unit)
@ -4272,27 +4662,76 @@ function CTLD:UnloadTroops(group, opts)
local troopOffset = math.max(0, tonumber(self.Config.TroopSpawnOffset or 0) or 0)
local center = (troopOffset > 0) and { x = here.x + math.sin(hdgRad) * troopOffset, z = here.z + math.cos(hdgRad) * troopOffset } or { x = here.x, z = here.z }
-- Build the unit composition based on type
local comp, _ = self:_resolveTroopUnits(load.typeKey)
-- Build the unit composition - handle mixed troop types
local units = {}
local spacing = 1.8
for i=1, #comp do
local dx = (i-1) * spacing
local dz = ((i % 2) == 0) and 2.0 or -2.0
table.insert(units, {
type = tostring(comp[i] or 'Infantry AK'),
name = string.format('CTLD-TROOP-%d', math.random(100000,999999)),
x = center.x + dx, y = center.z + dz, heading = hdgRad
})
local unitIndex = 0
if load.troopTypes then
-- Mixed types - spawn each type's units
for _, troopTypeData in ipairs(load.troopTypes) do
local comp, _ = self:_resolveTroopUnits(troopTypeData.typeKey)
for i=1, #comp do
unitIndex = unitIndex + 1
local dx = (unitIndex-1) * spacing
local dz = ((unitIndex % 2) == 0) and 2.0 or -2.0
table.insert(units, {
type = tostring(comp[i] or 'Infantry AK'),
name = string.format('CTLD-TROOP-%d', math.random(100000,999999)),
x = center.x + dx, y = center.z + dz, heading = hdgRad
})
end
end
else
-- Single type (legacy support)
local comp, _ = self:_resolveTroopUnits(load.typeKey)
for i=1, #comp do
unitIndex = unitIndex + 1
local dx = (unitIndex-1) * spacing
local dz = ((unitIndex % 2) == 0) and 2.0 or -2.0
table.insert(units, {
type = tostring(comp[i] or 'Infantry AK'),
name = string.format('CTLD-TROOP-%d', math.random(100000,999999)),
x = center.x + dx, y = center.z + dz, heading = hdgRad
})
end
end
local groupData = {
visible=false, lateActivation=false, tasks={}, task='Ground Nothing',
units=units, route={}, name=string.format('CTLD_TROOPS_%d', math.random(100000,999999))
}
local spawned = _coalitionAddGroup(self.Side, Group.Category.GROUND, groupData, self.Config)
if spawned then
-- Track deployed troop groups for later pickup
local troopGroupName = spawned:getName()
CTLD._deployedTroops[troopGroupName] = {
typeKey = load.typeKey,
count = load.count,
side = self.Side,
spawnTime = timer.getTime(),
point = { x = center.x, z = center.z },
weightKg = load.weightKg or 0,
behavior = opts and opts.behavior or 'defend'
}
CTLD._troopsLoaded[gname] = nil
_eventSend(self, nil, self.Side, 'troops_unloaded_coalition', { count = #units, player = _playerNameFromGroup(group) })
-- Update DCS internal cargo weight after unloading troops
self:_updateCargoWeight(group)
-- Send appropriate message based on deployment method
if isFastRope then
local aircraftType = _getUnitType(unit) or 'aircraft'
_eventSend(self, nil, self.Side, 'troops_fast_roped_coalition', {
count = #units,
player = _playerNameFromGroup(group),
aircraft = aircraftType
})
else
_eventSend(self, nil, self.Side, 'troops_unloaded_coalition', { count = #units, player = _playerNameFromGroup(group) })
end
-- Assign optional behavior
local behavior = opts and opts.behavior or nil
if behavior == 'attack' and self.Config.AttackAI and self.Config.AttackAI.Enabled then
@ -4998,7 +5437,19 @@ function CTLD:_DeliverMEDEVACCrewToMASH(group, crewGroupName, crewData)
CTLD._medevacStats[self.Side].delivered = (CTLD._medevacStats[self.Side].delivered or 0) + 1
CTLD._medevacStats[self.Side].salvageEarned = (CTLD._medevacStats[self.Side].salvageEarned or 0) + crewData.salvageValue
end
-- Remove crew from tracking
-- Remove map marker
if crewData.markerID then
pcall(function() trigger.action.removeMark(crewData.markerID) end)
end
-- Destroy crew group to prevent clutter
local crewGroup = Group.getByName(crewGroupName)
if crewGroup and crewGroup:isExist() then
crewGroup:destroy()
end
-- Remove crew from tracking
CTLD._medevacCrews[crewGroupName] = nil
env.info(string.format('[Moose_CTLD][MEDEVAC] Delivered %s crew to MASH - awarded %d salvage (total: %d)',

Binary file not shown.