diff --git a/Moose_CTLD_Pure/MEDEVAC_Salvage_System_Guide.html b/Moose_CTLD_Pure/MEDEVAC_Salvage_System_Guide.html new file mode 100644 index 0000000..4ace6f5 --- /dev/null +++ b/Moose_CTLD_Pure/MEDEVAC_Salvage_System_Guide.html @@ -0,0 +1,752 @@ + + + + + + MOOSE CTLD: MEDEVAC & Salvage System Guide + + + +
+

๐Ÿš MOOSE CTLD: COMPLETE MEDEVAC & SALVAGE SYSTEM GUIDE ๐Ÿ“ฆ

+

Dynamic Battlefield Economy & Rescue Operations

+ +
+

OVERVIEW

+

The CTLD system features a comprehensive salvage economy with TWO distinct methods for earning salvage points: MEDEVAC crew rescue missions and Sling-Load salvage crate recovery. Both systems feed into a shared coalition salvage pool that can be used to build out-of-stock equipment.

+
+ +

๐Ÿ“‹ Complete Menu Structure

+ + +

๐Ÿš‘ SALVAGE METHOD #1: MEDEVAC RESCUE CREW RECOVERY

+ +

CONCEPT: Rescue stranded vehicle crews and deliver them to MASH zones

+ +

How It Works

+
    +
  1. When friendly vehicles are destroyed, there's a % chance the crew survives
    + (Default: 50% survival chance, configurable per coalition)
  2. + +
  3. After a 5-minute delay (battle clearance), surviving crew spawns near the wreck with a MEDEVAC mission announcement
  4. + +
  5. Crew will: +
      +
    • Pop smoke when rescue helicopter approaches (8km detection)
    • +
    • Send colorful radio messages ("Follow the smoke!")
    • +
    • Wait up to 1 hour before being declared KIA
    • +
    • May include MANPADS soldier for self-defense (10% chance)
    • +
    +
  6. + +
  7. Load crew via normal troop pickup (land near crew, auto-loads)
  8. + +
  9. Fly to any MASH (Mobile Army Surgical Hospital) zone
  10. + +
  11. Land in MASH zone and wait 15 seconds for automatic crew offload
  12. + +
  13. Salvage points awarded based on vehicle value!
  14. +
+ +

Salvage Value Scale

+

Value is determined by the destroyed vehicle type (from catalog):

+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Vehicle TypeSalvage PointsExamples
Light Vehicles1-5 pointsHumvees, trucks, light armor
Medium Vehicles5-15 pointsAPCs, IFVs, light tanks
Heavy Vehicles15-30 pointsMBTs, heavy armor
Special Assets30-50 pointsSAMs, artillery, C2 vehicles
+ +
+ Example: Rescue a T-90 crew = ~25 salvage points +
+ +

MEDEVAC F10 Menu Commands

+ + +

๐Ÿ“ฆ SALVAGE METHOD #2: SLING-LOAD RECOVERY EQUIPMENT SALVAGE

+ +

CONCEPT: Recover enemy equipment wreckage via DCS sling-load mechanics

+ +

How It Works

+
    +
  1. When ENEMY ground units die, there's a 15% chance (configurable) to spawn a physical cargo crate near the wreck for YOUR coalition to collect
  2. + +
  3. Crate naming: +
      +
    • SALVAGE-B-XXXXXX (Blue coalition can collect, spawns from RED deaths)
    • +
    • SALVAGE-R-XXXXXX (Red coalition can collect, spawns from BLUE deaths)
    • +
    +
  4. + +
  5. Orange smoke marks crate location for 2 minutes after spawn
  6. + +
  7. Crate has a random weight class (determines helicopter requirement & reward)
  8. + +
  9. Use standard DCS F6 RADIO MENU sling-load to hook the crate
  10. + +
  11. Fly to a Salvage Collection Zone (create via F10 > Field Tools menu)
  12. + +
  13. Land/drop crate inside the zone boundary
  14. + +
  15. Automatic detection awards salvage points based on weight + condition!
  16. + +
  17. Crate expires after 3 HOURS if not collected (warnings at 30min & 5min)
  18. +
+ +
+ NOT hover-pickup! This is pure DCS sling-load mechanics using F6 RADIO MENU! +
+ +

Weight Classes & Reward Matrix

+

Crate weight determines both helicopter requirements and base reward value:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Weight ClassWeight RangeHelicopterBase RewardSpawn Chance
Light1500-2500 kgUH-1H Huey, UH-602 pts/500kg50%
Medium2501-5000 kgMi-8 Hip, Ka-503 pts/500kg30%
Heavy5001-8000 kgLarge Helos5 pts/500kg15%
Super Heavy8001-12000 kgCH-47 Chinook ONLY8 pts/500kg5%
+ +

Reward Calculation Formula

+
+ Final Reward = (Weight รท 500) ร— Base Multiplier ร— Condition Bonus +

+ Example Light Crate (2000kg): + +
+ +

โš ๏ธ Condition-Based Multipliers - FLY CAREFULLY!

+

Crate health affects your reward! Damage reduces salvage value:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ConditionHealth RangeMultiplierExample (8pt base)
UNDAMAGED โœ“โ‰ฅ 90% health1.5x12 points (+50% BONUS!)
Damaged50-89% health1.0x8 points (normal)
Heavy Damage โš < 50% health0.5x4 points (-50% penalty)
DESTROYED โœ—0% health0x0 points (crate lost)
+ +
+ Smooth flying = 50% bonus! Crash landing = 50% penalty! +
+ +

Example Salvage Scenarios

+ +
+

Scenario A: Light Crate, Perfect Delivery

+ +
+ +
+

Scenario B: Medium Crate, Rough Landing

+ +
+ +
+

Scenario C: Super Heavy Crate, Crashed

+ +
+ +
+

Scenario D: Heavy Crate, Destroyed

+ +
+ +

Sling-Load Salvage F10 Menu Commands

+ + +

Spawn Restrictions

+

Salvage crates will NOT spawn:

+ + +

Lifecycle & Warnings

+ + +

๐Ÿ’ฐ Using Salvage Points

+ +

What Are Salvage Points?

+

Salvage points are a coalition-wide resource pool that allows you to build equipment that is normally out-of-stock at your current location.

+ +

When Salvage Is Used

+

Salvage auto-applies when:

+
    +
  1. You request a crate that is OUT OF STOCK at nearest supply zone
  2. +
  3. Coalition has enough salvage points to cover the cost
  4. +
  5. Cost = item's required crate count (e.g., M1 Abrams = 3 crates = 3 salvage)
  6. +
+ +

Salvage Balance

+

Check your coalition's salvage point balance:

+

F10 > Operations > MEDEVAC > Coalition Salvage Points

+

Or build/request menu will show salvage balance when out-of-stock

+ +

๐ŸŽฏ Strategic Considerations

+ +
+
+

MEDEVAC Advantages

+
    +
  • โœ“ More consistent rewards (vehicle value-based)
  • +
  • โœ“ Easier execution (normal troop pickup + land at MASH)
  • +
  • โœ“ Lower skill requirement
  • +
  • โœ“ Supports role-play/immersion
  • +
  • โœ“ No condition penalties
  • +
+
+
+

Sling-Load Salvage Advantages

+
    +
  • โœ“ Higher potential rewards (up to 160pts for perfect Chinook delivery!)
  • +
  • โœ“ More frequent opportunities (every enemy kill = 15% chance)
  • +
  • โœ“ Skill-based system (rewards good flying)
  • +
  • โœ“ Can be done solo or coordinated
  • +
  • โœ“ Creates dynamic battlefield scavenging gameplay
  • +
+
+
+ +

Combined Strategy

+

Smart coalitions will:

+ + +

๐Ÿš€ Quick Reference

+ +
+
+

MEDEVAC Quick Steps

+
    +
  1. Listen for MEDEVAC announcement (friendly vehicle crew spawned)
  2. +
  3. F10 > Ops > MEDEVAC > Vectors to Nearest Crew
  4. +
  5. Fly to location, follow smoke
  6. +
  7. Land near crew (auto-loads like troops)
  8. +
  9. Fly to MASH zone
  10. +
  11. Land and wait 15 seconds
  12. +
  13. Salvage awarded! Vehicle respawns shortly after.
  14. +
+
+ +
+

Sling-Load Salvage Quick Steps

+
    +
  1. Listen for salvage spawn announcement (enemy died โ†’ crate spawned)
  2. +
  3. F10 > Navigation > Vectors to Nearest Salvage Crate
  4. +
  5. Fly to location (orange smoke = crate)
  6. +
  7. Use DCS F6 RADIO MENU > Sling Load > Hook Cargo
  8. +
  9. Fly carefully to Salvage Collection Zone
  10. +
  11. Land or drop crate inside zone
  12. +
  13. Salvage awarded based on weight + condition!
  14. +
+
+
+ +

Key Differences

+ + + + + + + + + + + + + + + + + + + + + + + + + + +
FeatureMEDEVACSling-Load Salvage
Pickup MethodHover pickup OR land โ†’ auto-loads troopsDCS F6 menu sling-load ONLY (not CTLD hover pickup!)
Reward TypeFixed value per vehicle typeVariable value based on weight + flying skill
Time Window1-hour window before crew KIA3-hour window before crate expires
Skill LevelEasy to MediumMedium to Hard (condition bonuses)
+ +

๐Ÿ”ง Troubleshooting

+ +

"I can't sling-load the salvage crate!"

+ + +

"Crate disappeared before I got there!"

+ + +

"I didn't get full reward for my delivery!"

+ + +

"No MEDEVAC missions spawning!"

+ + +

"Where do I create Salvage Collection Zones?"

+ + +

โš™๏ธ Configuration Notes

+

Mission makers can adjust:

+ +

All settings are per-coalition and fully configurable via the CTLD config table.

+ + +
+ + \ No newline at end of file diff --git a/Moose_CTLD_Pure/Moose_CTLD.lua b/Moose_CTLD_Pure/Moose_CTLD.lua index 35d4356..8215918 100644 --- a/Moose_CTLD_Pure/Moose_CTLD.lua +++ b/Moose_CTLD_Pure/Moose_CTLD.lua @@ -128,6 +128,19 @@ CTLD.Messages = { medevac_crew_warn_15min = "WARNING: {vehicle} crew at {grid} - rescue window expires in 15 minutes!", medevac_crew_warn_5min = "URGENT: {vehicle} crew at {grid} - rescue window expires in 5 minutes!", medevac_unload_hold = "MEDEVAC: Stay grounded in the MASH zone for {seconds} seconds to offload casualties.", + + -- Sling-Load Salvage messages + slingload_salvage_spawned = "SALVAGE OPPORTUNITY: Enemy wreckage at {grid}. Weight: {weight}kg, Est. Value: {reward}pts. {time_remain} to collect.", + slingload_salvage_delivered = "{player} delivered {weight}kg salvage for {reward} points ({condition})! Coalition total: {total}", + slingload_salvage_expired = "SALVAGE LOST: Crate {id} at {grid} deteriorated.", + slingload_salvage_damaged = "CAUTION: Salvage crate damaged in transit. Value reduced to {reward}pts.", + slingload_salvage_vectors = "Nearest salvage crate {id}: bearing {brg}ยฐ, range {rng} {rng_u}. Weight: {weight}kg, Value: {reward}pts.", + slingload_salvage_no_crates = "No active salvage crates available.", + slingload_salvage_zone_created = "Salvage Collection Zone '{zone}' created at your position (radius: {radius}m).", + slingload_salvage_zone_activated = "Salvage Collection Zone '{zone}' is now ACTIVE.", + slingload_salvage_zone_deactivated = "Salvage Collection Zone '{zone}' is now INACTIVE.", + slingload_salvage_warn_30min = "SALVAGE REMINDER: Crate {id} at {grid} expires in 30 minutes. Weight: {weight}kg.", + slingload_salvage_warn_5min = "SALVAGE URGENT: Crate {id} at {grid} expires in 5 minutes!", medevac_unload_aborted = "MEDEVAC: Unload aborted - {reason}. Land and hold for {seconds} seconds.", -- Mobile MASH messages @@ -248,7 +261,7 @@ CTLD.Config = { ForbidChecksActivePickupOnly = true, -- when true, restriction applies only to ACTIVE pickup zones; false blocks all configured pickup zones -- Dynamic Drop Zone settings - DropZoneRadius = 250, -- meters: radius used when creating a Drop Zone via the admin menu at player position + DropZoneRadius = 500, -- meters: radius used when creating a Drop Zone via the admin menu at player position MinDropZoneDistanceFromPickup = 2000, -- meters: minimum distance from nearest Pickup Zone required to create a dynamic Drop Zone (0 to disable) MinDropDistanceActivePickupOnly = true, -- when true, only ACTIVE pickup zones are considered for the minimum distance check @@ -325,6 +338,7 @@ CTLD.Config = { DrawDropZones = true, -- optionally draw Drop zones DrawFOBZones = true, -- optionally draw FOB zones DrawMASHZones = true, -- optionally draw MASH (medical) zones + DrawSalvageZones = true, -- optionally draw Salvage Collection zones FontSize = 18, -- label text size ReadOnly = true, -- prevent clients from removing the shapes ForAll = false, -- if true, draw shapes to all (-1) instead of coalition only (useful for testing/briefing) @@ -335,6 +349,7 @@ CTLD.Config = { Drop = {0, 0, 0, 0.25}, -- black fill for Drop zones FOB = {1, 1, 0, 0.15}, -- yellow fill for FOB zones MASH = {1, 0.75, 0.8, 0.25}, -- pink fill for MASH zones + SalvageDrop = {1, 0, 1, 0.15}, -- magenta fill for Salvage zones }, LineType = 1, -- default line type if per-kind is not set (0 None, 1 Solid, 2 Dashed, 3 Dotted, 4 DotDash, 5 LongDash, 6 TwoDash) LineTypes = { -- override border style per zone kind @@ -342,6 +357,7 @@ CTLD.Config = { Drop = 2, -- dashed FOB = 4, -- dot-dash MASH = 1, -- solid + SalvageDrop = 2, -- dashed }, -- Label placement tuning (simple): -- Effective extra offset from the circle edge = r * LabelOffsetRatio + LabelOffsetFromEdge @@ -354,6 +370,7 @@ CTLD.Config = { Drop = 'Drop Zone', FOB = 'FOB Zone', MASH = 'MASH Zone', + SalvageDrop = 'Salvage Collection Zone', } }, @@ -382,6 +399,66 @@ CTLD.Config = { DropZones = {}, -- Optional Drop/AO zones FOBZones = {}, -- FOB zones (restrict FOB building to these if RestrictFOBToZones = true) MASHZones = {}, -- Medical zones for MEDEVAC crew delivery (MASH = Mobile Army Surgical Hospital) + SalvageDropZones = {}, -- Salvage collection zones for sling-load salvage delivery + }, + + -- === Sling-Load Salvage System === + -- Spawn salvageable crates when enemy units are destroyed; deliver to collection zones for rewards + SlingLoadSalvage = { + Enabled = true, + + -- Spawn probability when enemy ground units die + SpawnChance = { + [coalition.side.BLUE] = 0.90, -- 90% chance when BLUE unit dies (RED can collect the salvage) + [coalition.side.RED] = 0.90, -- 90% chance when RED unit dies (BLUE can collect the salvage) + }, + + -- Weight classes with spawn probabilities and reward rates + WeightClasses = { + { name = 'Light', min = 1500, max = 2500, probability = 0.50, rewardPer500kg = 2 }, -- Huey-capable + { name = 'Medium', min = 2501, max = 5000, probability = 0.30, rewardPer500kg = 3 }, -- Hip/Mi-8 + { name = 'Heavy', min = 5001, max = 8000, probability = 0.15, rewardPer500kg = 5 }, -- Large helos + { name = 'SuperHeavy', min = 8001, max = 12000, probability = 0.05, rewardPer500kg = 8 }, -- Chinook only + }, + + -- Condition-based reward multipliers (based on crate health when delivered) + ConditionMultipliers = { + Undamaged = 1.5, -- >= 90% health + Damaged = 1.0, -- 50-89% health + HeavyDamage = 0.5, -- < 50% health + }, + + CrateLifetime = 10800, -- 3 hours (seconds) + WarningTimes = { 1800, 300 }, -- Warn at 30min and 5min remaining + + -- Visual indicators + SpawnSmoke = false, + SmokeDuration = 120, -- 2 minutes + SmokeColor = trigger.smokeColor.Orange, + + -- Spawn restrictions + MinSpawnDistance = 25, -- meters from death location + MaxSpawnDistance = 45, -- meters from death location + NoSpawnNearPickupZones = true, + NoSpawnNearPickupZoneDistance = 1000, -- meters + NoSpawnNearAirbasesKm = 1, + + DetectionInterval = 5, -- seconds between salvage zone checks + + -- Cargo static types (DCS sling-loadable cargo) + CargoTypes = { + 'container_cargo', + 'ammo_cargo', + 'fueltank_cargo', + 'barrels_cargo', + }, + + -- Salvage Collection Zone defaults + DefaultZoneRadius = 300, + ZoneColors = { + border = {1, 0.5, 0, 0.85}, -- orange border + fill = {1, 0.5, 0, 0.15}, -- light orange fill + }, }, } -- #endregion Config @@ -1215,6 +1292,16 @@ CTLD.MEDEVAC = { TrackByPlayer = false, -- if true, track per-player stats (not yet implemented) }, } + +-- ========================= +-- Sling-Load Salvage Configuration (MOVED) +-- ========================= +-- #region SlingLoadSalvage Config +-- NOTE: SlingLoadSalvage configuration has been MOVED into CTLD.Config.SlingLoadSalvage +-- so that it properly gets copied to each CTLD instance via DeepCopy/DeepMerge. +-- The old CTLD.SlingLoadSalvage global definition here is removed to avoid confusion. +-- See CTLD.Config.SlingLoadSalvage above for the actual configuration. +-- #endregion SlingLoadSalvage Config --=================================================================================================================================================== -- #endregion MEDEVAC Config @@ -1257,6 +1344,14 @@ CTLD._medevacUnloadStates = CTLD._medevacUnloadStates or {} -- [groupName] = { s CTLD._medevacLoadStates = CTLD._medevacLoadStates or {} -- [groupName] = { startTime, delay, crewGroupName, crewData, holdAnnounced, nextReminder } CTLD._medevacEnrouteStates = CTLD._medevacEnrouteStates or {} -- [groupName] = { nextSend, lastIndex } +-- Sling-Load Salvage state +CTLD._salvageCrates = CTLD._salvageCrates or {} -- [crateName] = { side, weight, spawnTime, position, initialHealth, rewardValue, warningsSent, staticObject, crateClass } +CTLD._salvageDropZones = CTLD._salvageDropZones or {} -- [zoneName] = { zone, side, active } +CTLD._salvageStats = CTLD._salvageStats or { -- [coalition.side] = { spawned, delivered, expired, totalWeight, totalReward } + [coalition.side.BLUE] = { spawned = 0, delivered = 0, expired = 0, totalWeight = 0, totalReward = 0 }, + [coalition.side.RED] = { spawned = 0, delivered = 0, expired = 0, totalWeight = 0, totalReward = 0 }, +} + -- #endregion State -- ========================= @@ -2564,6 +2659,17 @@ function CTLD:DrawZonesOnMap() end end end + if md.DrawSalvageZones then + for _,mz in ipairs(self.SalvageDropZones or {}) do + local name = mz:GetName() + if self._ZoneActive.SalvageDrop[name] ~= false then + opts.LabelPrefix = (md.LabelPrefixes and md.LabelPrefixes.SalvageDrop) or 'Salvage Zone' + opts.LineType = (md.LineTypes and md.LineTypes.SalvageDrop) or md.LineType or 1 + opts.FillColor = (md.FillColors and md.FillColors.SalvageDrop) or self.Config.SlingLoadSalvage.ZoneColors.fill + self:_drawZoneCircleAndLabel('SalvageDrop', mz, opts) + end + end + end end -- Unit preference detection and unit-aware formatting @@ -3032,6 +3138,7 @@ function CTLD:New(cfg) pushFromZones('Drop', o.Config.Zones and o.Config.Zones.DropZones) pushFromZones('FOB', o.Config.Zones and o.Config.Zones.FOBZones) pushFromZones('MASH', o.Config.Zones and o.Config.Zones.MASHZones) + pushFromZones('SalvageDrop', o.Config.Zones and o.Config.Zones.SalvageDropZones) o._BindingsMerged = merged if o._BindingsMerged and #o._BindingsMerged > 0 then @@ -3137,8 +3244,9 @@ function CTLD:InitZones() self.DropZones = {} self.FOBZones = {} self.MASHZones = {} - self._ZoneDefs = { PickupZones = {}, DropZones = {}, FOBZones = {}, MASHZones = {} } - self._ZoneActive = { Pickup = {}, Drop = {}, FOB = {}, MASH = {} } + self.SalvageDropZones = {} + self._ZoneDefs = { PickupZones = {}, DropZones = {}, FOBZones = {}, MASHZones = {}, SalvageDropZones = {} } + self._ZoneActive = { Pickup = {}, Drop = {}, FOB = {}, MASH = {}, SalvageDrop = {} } for _,z in ipairs(self.Config.Zones.PickupZones or {}) do local mz = _findZone(z) if mz then @@ -3175,6 +3283,15 @@ function CTLD:InitZones() if self._ZoneActive.MASH[name] == nil then self._ZoneActive.MASH[name] = (z.active ~= false) end end end + for _,z in ipairs(self.Config.Zones.SalvageDropZones or {}) do + local mz = _findZone(z) + if mz then + table.insert(self.SalvageDropZones, mz) + local name = mz:GetName() + self._ZoneDefs.SalvageDropZones[name] = z + if self._ZoneActive.SalvageDrop[name] == nil then self._ZoneActive.SalvageDrop[name] = (z.active ~= false) end + end + end end -- Validate configured zone names exist in the mission; warn coalition if any are missing. @@ -3207,9 +3324,9 @@ function CTLD:ValidateZones() return s end - local missing = { Pickup = {}, Drop = {}, FOB = {}, MASH = {} } - local found = { Pickup = {}, Drop = {}, FOB = {}, MASH = {} } - local coords = { Pickup = 0, Drop = 0, FOB = 0, MASH = 0 } + local missing = { Pickup = {}, Drop = {}, FOB = {}, MASH = {}, SalvageDrop = {} } + local found = { Pickup = {}, Drop = {}, FOB = {}, MASH = {}, SalvageDrop = {} } + local coords = { Pickup = 0, Drop = 0, FOB = 0, MASH = 0, SalvageDrop = 0 } for _,z in ipairs(self.Config.Zones.PickupZones or {}) do if z.name then @@ -3239,6 +3356,13 @@ function CTLD:ValidateZones() coords.MASH = coords.MASH + 1 end end + for _,z in ipairs(self.Config.Zones.SalvageDropZones or {}) do + if z.name then + if zoneExistsByName(z.name) then table.insert(found.SalvageDrop, z.name) else table.insert(missing.SalvageDrop, z.name) end + elseif z.coord then + coords.SalvageDrop = coords.SalvageDrop + 1 + end + end -- Log a concise summary to dcs.log local sideStr = sideToStr(self.Side) @@ -3254,8 +3378,11 @@ function CTLD:ValidateZones() _logVerbose(string.format('[ZoneValidation][%s] MASH : configured=%d (named=%d, coord=%d) found=%d missing=%d', sideStr, #(self.Config.Zones.MASHZones or {}), #found.MASH + #missing.MASH, coords.MASH, #found.MASH, #missing.MASH)) + _logVerbose(string.format('[ZoneValidation][%s] Salvage: configured=%d (named=%d, coord=%d) found=%d missing=%d', + sideStr, + #(self.Config.Zones.SalvageDropZones or {}), #found.SalvageDrop + #missing.SalvageDrop, coords.SalvageDrop, #found.SalvageDrop, #missing.SalvageDrop)) - local anyMissing = (#missing.Pickup > 0) or (#missing.Drop > 0) or (#missing.FOB > 0) or (#missing.MASH > 0) + local anyMissing = (#missing.Pickup > 0) or (#missing.Drop > 0) or (#missing.FOB > 0) or (#missing.MASH > 0) or (#missing.SalvageDrop > 0) if anyMissing then if #missing.Pickup > 0 then local msg = 'CTLD config warning: Missing Pickup Zones: '..join(missing.Pickup) @@ -3273,6 +3400,10 @@ function CTLD:ValidateZones() local msg = 'CTLD config warning: Missing MASH Zones: '..join(missing.MASH) _msgCoalition(self.Side, msg); _logError('[ZoneValidation]['..sideStr..'] '..msg) end + if #missing.SalvageDrop > 0 then + local msg = 'CTLD config warning: Missing Salvage Drop Zones: '..join(missing.SalvageDrop) + _msgCoalition(self.Side, msg); _logError('[ZoneValidation]['..sideStr..'] '..msg) + end else _logVerbose(string.format('[ZoneValidation][%s] All configured zone names resolved successfully.', sideStr)) end @@ -3765,6 +3896,15 @@ function CTLD:BuildGroupMenus(group) -- Field Tools CMD('Create Drop Zone (AO)', toolsRoot, function() self:CreateDropZoneAtGroup(group) end) + + -- Salvage Collection Zones submenu + if self.Config.SlingLoadSalvage and self.Config.SlingLoadSalvage.Enabled then + local salvageZoneRoot = MENU_GROUP:New(group, 'Salvage Collection Zones', toolsRoot) + CMD('Create Salvage Zone Here', salvageZoneRoot, function() self:CreateSalvageZoneAtGroup(group) end) + CMD('Show Active Salvage Zones', salvageZoneRoot, function() self:ShowActiveSalvageZones(group) end) + -- Dynamic per-zone management will be added by _rebuildSalvageZoneMenus + end + local smokeRoot = MENU_GROUP:New(group, 'Smoke My Location', toolsRoot) local function smokeHere(color) local unit = group:GetUnit(1) @@ -3806,6 +3946,12 @@ function CTLD:BuildGroupMenus(group) _msgGroup(group, 'No friendly crates found.') end end) + + -- Sling-Load Salvage vectors + if self.Config.SlingLoadSalvage and self.Config.SlingLoadSalvage.Enabled then + CMD('Vectors to Nearest Salvage Crate', navRoot, function() self:ShowNearestSalvageCrate(group) end) + end + CMD('Vectors to Nearest Pickup Zone', navRoot, function() local unit = group:GetUnit(1) if not unit or not unit:IsAlive() then return end @@ -7404,6 +7550,26 @@ function CTLD:InitMEDEVAC() _logDebug(string.format('[MEDEVAC] OnEventDead: %s not found in catalog', unitType)) end end + + -- Sling-Load Salvage: Check if we should spawn a salvage crate for the OPPOSING coalition + if selfref.Config.SlingLoadSalvage and selfref.Config.SlingLoadSalvage.Enabled then + -- Get unit position + local unitPos = nil + if eventData.initiator and eventData.initiator.getPoint then + local success, point = pcall(function() return eventData.initiator:getPoint() end) + if success and point then + unitPos = point + end + end + + if unitPos then + -- Determine enemy coalition (who can collect this salvage) + local enemySide = (selfref.Side == coalition.side.BLUE) and coalition.side.RED or coalition.side.BLUE + selfref:_SpawnSlingLoadSalvageCrate(unitPos, unitType, enemySide, eventData) + else + _logDebug('[SlingLoadSalvage] Could not get unit position for salvage spawn') + end + end end self.MEDEVACHandler = handler @@ -7441,6 +7607,15 @@ function CTLD:InitMEDEVAC() selfref:_CheckMEDEVACTimeouts() end, {}, 30, 30) + -- Start sling-load salvage crate checker (runs every 5 seconds by default) + if self.Config.SlingLoadSalvage and self.Config.SlingLoadSalvage.Enabled then + local interval = self.Config.SlingLoadSalvage.DetectionInterval or 5 + self.SalvageSched = SCHEDULER:New(nil, function() + selfref:_CheckSlingLoadSalvageCrates() + end, {}, interval, interval) + _logInfo('Sling-Load Salvage system initialized for coalition '..tostring(self.Side)) + end + -- Initialize MASH zones from config self:_InitMASHZones() @@ -10189,6 +10364,14 @@ function CTLD:Cleanup() -- Stop any MEDEVAC timeout checkers or other schedulers -- (If you add schedulers in the future, stop them here) + if self.MEDEVACSched then + pcall(function() self.MEDEVACSched:Stop() end) + self.MEDEVACSched = nil + end + if self.SalvageSched then + pcall(function() self.SalvageSched:Stop() end) + self.SalvageSched = nil + end -- Clear spatial grid CTLD._spatialGrid = {} @@ -10206,6 +10389,16 @@ function CTLD:Cleanup() CTLD._buildConfirm = {} CTLD._buildCooldown = {} CTLD._jtacReservedCodes = { [coalition.side.BLUE] = {}, [coalition.side.RED] = {}, [coalition.side.NEUTRAL] = {} } + + -- Clear salvage state + if CTLD._salvageCrates then + for crateName, meta in pairs(CTLD._salvageCrates) do + if meta.staticObject and meta.staticObject.destroy then + pcall(function() meta.staticObject:destroy() end) + end + end + CTLD._salvageCrates = {} + end if self.JTACSched then pcall(function() self.JTACSched:Stop() end) self.JTACSched = nil @@ -10245,6 +10438,499 @@ end -- #endregion Public helpers +-- ========================= +-- Sling-Load Salvage System +-- ========================= +-- #region SlingLoadSalvage + +-- Spawn a salvage crate when an enemy ground unit dies +function CTLD:_SpawnSlingLoadSalvageCrate(unitPos, unitTypeName, enemySide, eventData) + local cfg = self.Config.SlingLoadSalvage + if not cfg or not cfg.Enabled then return end + + -- Check spawn chance for this coalition + local spawnChance = cfg.SpawnChance[enemySide] or 0.15 + if math.random() > spawnChance then + _logVerbose(string.format('[SlingLoadSalvage] Spawn roll failed (%.2f chance)', spawnChance)) + return + end + + -- Check spawn restrictions + if cfg.NoSpawnNearPickupZones then + local minDist = cfg.NoSpawnNearPickupZoneDistance or 1000 + for _, zone in ipairs(self.PickupZones or {}) do + local zoneName = zone:GetName() + if zoneName and (self._ZoneActive.Pickup[zoneName] ~= false) then + local zonePos = zone:GetPointVec3() + local dist = math.sqrt((unitPos.x - zonePos.x)^2 + (unitPos.z - zonePos.z)^2) + if dist < minDist then + _logVerbose('[SlingLoadSalvage] Too close to pickup zone, aborting spawn') + return + end + end + end + end + + if cfg.NoSpawnNearAirbasesKm and cfg.NoSpawnNearAirbasesKm > 0 then + local airbases = coalition.getAirbases(enemySide) + if airbases then + local minDistKm = cfg.NoSpawnNearAirbasesKm * 1000 + for _, ab in ipairs(airbases) do + local abPos = ab:getPoint() + local dist = math.sqrt((unitPos.x - abPos.x)^2 + (unitPos.z - abPos.z)^2) + if dist < minDistKm then + _logVerbose('[SlingLoadSalvage] Too close to airbase, aborting spawn') + return + end + end + end + end + + -- Select weight class + local totalProb = 0 + for _, wc in ipairs(cfg.WeightClasses) do + totalProb = totalProb + wc.probability + end + local roll = math.random() * totalProb + local cumulative = 0 + local selectedClass = cfg.WeightClasses[1] -- fallback + for _, wc in ipairs(cfg.WeightClasses) do + cumulative = cumulative + wc.probability + if roll <= cumulative then + selectedClass = wc + break + end + end + + local weight = math.random(selectedClass.min, selectedClass.max) + local rewardValue = math.floor((weight / 500) * selectedClass.rewardPer500kg) + + -- Calculate spawn position + local minDist = cfg.MinSpawnDistance or 10 + local maxDist = cfg.MaxSpawnDistance or 25 + local distance = minDist + math.random() * (maxDist - minDist) + local angle = math.random() * 2 * math.pi + local spawnPos = { + x = unitPos.x + math.cos(angle) * distance, + z = unitPos.z + math.sin(angle) * distance + } + + -- Get land height + local landHeight = land.getHeight({ x = spawnPos.x, y = spawnPos.z }) + + -- Select cargo type based on weight + local cargoType + if weight < 1500 then + -- Light: barrels or ammo pallets + local lightTypes = { 'barrels_cargo', 'ammo_cargo' } + cargoType = lightTypes[math.random(1, #lightTypes)] + elseif weight < 2500 then + -- Medium: fuel tanks or containers + local mediumTypes = { 'fueltank_cargo', 'container_cargo', 'ammo_cargo' } + cargoType = mediumTypes[math.random(1, #mediumTypes)] + else + -- Heavy: large containers only + cargoType = 'container_cargo' + end + + -- Create unique crate name + local sidePrefix = (enemySide == coalition.side.BLUE) and 'R' or 'B' + local crateName = string.format('SALVAGE-%s-%06d', sidePrefix, math.random(100000, 999999)) + + -- Spawn the static cargo + local countryId = self.CountryId + if eventData and eventData.initiator and eventData.initiator.getCountry then + local success, result = pcall(function() return eventData.initiator:getCountry() end) + if success and result then + countryId = result + end + end + + local staticData = { + ['type'] = cargoType, + ['name'] = crateName, + ['x'] = spawnPos.x, + ['y'] = spawnPos.z, + ['heading'] = math.random() * 2 * math.pi, + ['canCargo'] = true, + ['mass'] = weight, + } + + local success, staticObj = pcall(function() + return coalition.addStaticObject(countryId, staticData) + end) + + if not success or not staticObj then + _logError('[SlingLoadSalvage] Failed to spawn salvage crate: ' .. tostring(staticObj)) + return + end + + -- Store crate metadata + CTLD._salvageCrates[crateName] = { + side = enemySide, + weight = weight, + spawnTime = timer.getTime(), + position = spawnPos, + initialHealth = 1.0, + rewardValue = rewardValue, + warningsSent = {}, + staticObject = staticObj, + crateClass = selectedClass.name, + } + + -- Update stats + if not CTLD._salvageStats[enemySide] then + CTLD._salvageStats[enemySide] = { spawned = 0, delivered = 0, expired = 0, totalWeight = 0, totalReward = 0 } + end + CTLD._salvageStats[enemySide].spawned = CTLD._salvageStats[enemySide].spawned + 1 + + -- Spawn smoke if enabled + if cfg.SpawnSmoke then + local smokePos = { x = spawnPos.x, y = landHeight, z = spawnPos.z } + trigger.action.smoke(smokePos, cfg.SmokeColor or trigger.smokeColor.Orange) + end + + -- Calculate expiration time + local lifetime = cfg.CrateLifetime or 10800 + local timeRemainMin = math.floor(lifetime / 60) + local grid = self:_GetMGRSString(spawnPos) + + -- Announce to coalition + local msg = _fmtTemplate(self.Messages.slingload_salvage_spawned, { + grid = grid, + weight = weight, + reward = rewardValue, + time_remain = timeRemainMin, + }) + _msgCoalition(enemySide, msg) + + _logInfo(string.format('[SlingLoadSalvage] Spawned %s: weight=%dkg, reward=%dpts at %s', + crateName, weight, rewardValue, grid)) +end + +-- Check salvage crates for delivery and cleanup +function CTLD:_CheckSlingLoadSalvageCrates() + local cfg = self.Config.SlingLoadSalvage + if not cfg or not cfg.Enabled then return end + + local now = timer.getTime() + local cratesToRemove = {} + + for crateName, meta in pairs(CTLD._salvageCrates) do + if meta.side == self.Side then + local elapsed = now - meta.spawnTime + local lifetime = cfg.CrateLifetime or 10800 + + -- Check for expiration + if elapsed >= lifetime then + table.insert(cratesToRemove, crateName) + + -- Update stats + CTLD._salvageStats[meta.side].expired = CTLD._salvageStats[meta.side].expired + 1 + + -- Announce expiration + local grid = self:_GetMGRSString(meta.position) + local msg = _fmtTemplate(self.Messages.slingload_salvage_expired, { + id = crateName, + grid = grid, + }) + _msgCoalition(meta.side, msg) + + -- Remove the static object + if meta.staticObject and meta.staticObject.destroy then + pcall(function() meta.staticObject:destroy() end) + end + + _logVerbose(string.format('[SlingLoadSalvage] Crate %s expired', crateName)) + + else + -- Check for warnings + local remaining = lifetime - elapsed + for _, warnTime in ipairs(cfg.WarningTimes or { 1800, 300 }) do + if remaining <= warnTime and not meta.warningsSent[warnTime] then + meta.warningsSent[warnTime] = true + local grid = self:_GetMGRSString(meta.position) + local msgKey = (warnTime >= 1800) and 'slingload_salvage_warn_30min' or 'slingload_salvage_warn_5min' + local msg = _fmtTemplate(self.Messages[msgKey], { + id = crateName, + grid = grid, + weight = meta.weight, + }) + _msgCoalition(meta.side, msg) + end + end + + -- Check if crate is in a salvage zone + if meta.staticObject and meta.staticObject:isExist() then + local cratePos = meta.staticObject:getPoint() + if cratePos then + -- Check all salvage zones for this coalition + for _, zone in ipairs(self.SalvageDropZones or {}) do + local zoneName = zone:GetName() + local zoneDef = self._ZoneDefs.SalvageDropZones[zoneName] + + if zoneDef and zoneDef.side == meta.side and (self._ZoneActive.SalvageDrop[zoneName] ~= false) then + if zone:IsPointVec3InZone(cratePos) then + -- Check if crate is sling-loaded (has a parent) + local isLoaded = false + if meta.staticObject.getCargoDisplayName then + -- Crate is NOT on the ground if it's being carried + -- We detect delivery when crate is IN zone AND on ground (not sling-loaded) + local cargoWeight = meta.staticObject:getCargoWeight() + if cargoWeight and cargoWeight > 0 then + -- Crate exists and is on ground in zone - DELIVER IT + self:_DeliverSlingLoadSalvageCrate(crateName, meta, zoneName) + table.insert(cratesToRemove, crateName) + break + end + else + -- Fallback: just check if in zone + self:_DeliverSlingLoadSalvageCrate(crateName, meta, zoneName) + table.insert(cratesToRemove, crateName) + break + end + end + end + end + end + else + -- Crate no longer exists (destroyed or removed) + table.insert(cratesToRemove, crateName) + _logVerbose(string.format('[SlingLoadSalvage] Crate %s no longer exists', crateName)) + end + end + end + end + + -- Remove processed crates + for _, crateName in ipairs(cratesToRemove) do + CTLD._salvageCrates[crateName] = nil + end +end + +-- Deliver a salvage crate and award points +function CTLD:_DeliverSlingLoadSalvageCrate(crateName, meta, zoneName) + local cfg = self.Config.SlingLoadSalvage + + -- Check crate health for condition multiplier + local healthRatio = 1.0 + if meta.staticObject and meta.staticObject.getLife then + local success, currentLife = pcall(function() return meta.staticObject:getLife() end) + if success and currentLife then + local success2, maxLife = pcall(function() return meta.staticObject:getLife0() end) + if success2 and maxLife and maxLife > 0 then + healthRatio = currentLife / maxLife + end + end + end + + -- Determine condition multiplier + local conditionMult = cfg.ConditionMultipliers.Damaged or 1.0 + local conditionLabel = "Damaged" + if healthRatio >= 0.9 then + conditionMult = cfg.ConditionMultipliers.Undamaged or 1.5 + conditionLabel = "Undamaged" + elseif healthRatio < 0.5 then + conditionMult = cfg.ConditionMultipliers.HeavyDamage or 0.5 + conditionLabel = "Heavy Damage" + end + + -- Calculate final reward + local finalReward = math.floor(meta.rewardValue * conditionMult) + + -- Award salvage points + CTLD._salvagePoints[meta.side] = (CTLD._salvagePoints[meta.side] or 0) + finalReward + + -- Update stats + CTLD._salvageStats[meta.side].delivered = CTLD._salvageStats[meta.side].delivered + 1 + CTLD._salvageStats[meta.side].totalWeight = CTLD._salvageStats[meta.side].totalWeight + meta.weight + CTLD._salvageStats[meta.side].totalReward = CTLD._salvageStats[meta.side].totalReward + finalReward + + -- Find the player who delivered (nearest transport helo in zone) + local playerName = "Unknown Pilot" + local deliveryUnit = nil + for _, zone in ipairs(self.SalvageDropZones or {}) do + if zone:GetName() == zoneName then + -- Find nearby friendly helicopters + local zonePos = zone:GetPointVec3() + local radius = self:_getZoneRadius(zone) or 300 + local nearbyUnits = {} + + -- Search for units in the zone + local sphere = { + point = zonePos, + radius = radius, + } + + local foundUnits = {} + world.searchObjects(Object.Category.UNIT, sphere, function(obj) + if obj and obj:isExist() and obj.getCoalition then + local objCoal = obj:getCoalition() + if objCoal == meta.side and obj.getGroup then + local grp = obj:getGroup() + if grp then + local grpName = grp:getName() + table.insert(foundUnits, { unit = obj, group = grp, groupName = grpName }) + end + end + end + return true + end) + + -- Find player name from group + if #foundUnits > 0 then + deliveryUnit = foundUnits[1].unit + local grpName = foundUnits[1].groupName + if grpName then + -- Try to extract player name from group + local mooseGrp = GROUP:FindByName(grpName) + if mooseGrp then + local unit1 = mooseGrp:GetUnit(1) + if unit1 then + local pName = unit1:GetPlayerName() + if pName and pName ~= '' then + playerName = pName + else + playerName = grpName + end + end + end + end + end + break + end + end + + -- Announce delivery + local msg = _fmtTemplate(self.Messages.slingload_salvage_delivered, { + player = playerName, + weight = meta.weight, + reward = finalReward, + condition = conditionLabel, + total = CTLD._salvagePoints[meta.side], + }) + _msgCoalition(meta.side, msg) + + -- Remove the crate + if meta.staticObject and meta.staticObject.destroy then + pcall(function() meta.staticObject:destroy() end) + end + + _logInfo(string.format('[SlingLoadSalvage] %s delivered %s: %dkg, %dpts (%s), total=%d', + playerName, crateName, meta.weight, finalReward, conditionLabel, CTLD._salvagePoints[meta.side])) +end + +-- Menu: Create Salvage Zone at group position +function CTLD:CreateSalvageZoneAtGroup(group) + local cfg = self.Config.SlingLoadSalvage + if not cfg or not cfg.Enabled then + _msgGroup(group, 'Sling-Load Salvage system is disabled.') + return + end + + local unit = group:GetUnit(1) + if not unit or not unit:IsAlive() then return end + + local pos = unit:GetPointVec3() + local coord = COORDINATE:NewFromVec3(pos) + local radius = cfg.DefaultZoneRadius or 300 + + -- Generate unique zone name + local zoneName = string.format('SalvageZone-%s-%d', (self.Side == coalition.side.BLUE and 'BLUE' or 'RED'), + math.random(1000, 9999)) + + -- Create MOOSE zone + local zone = ZONE_RADIUS:New(zoneName, coord:GetVec2(), radius) + + -- Add to instance zones + table.insert(self.SalvageDropZones, zone) + self._ZoneDefs.SalvageDropZones[zoneName] = { name = zoneName, side = self.Side, active = true } + self._ZoneActive.SalvageDrop[zoneName] = true + + -- Announce + local msg = _fmtTemplate(self.Messages.slingload_salvage_zone_created, { + zone = zoneName, + radius = radius, + }) + _msgGroup(group, msg) + + _logInfo(string.format('[SlingLoadSalvage] Created zone %s at %s', zoneName, coord:ToStringLLDMS())) +end + +-- Menu: Show active salvage zones +function CTLD:ShowActiveSalvageZones(group) + local cfg = self.Config.SlingLoadSalvage + if not cfg or not cfg.Enabled then return end + + local activeZones = {} + for _, zone in ipairs(self.SalvageDropZones or {}) do + local zoneName = zone:GetName() + if self._ZoneActive.SalvageDrop[zoneName] ~= false then + local zoneDef = self._ZoneDefs.SalvageDropZones[zoneName] + if zoneDef and zoneDef.side == self.Side then + table.insert(activeZones, zoneName) + end + end + end + + if #activeZones == 0 then + _msgGroup(group, 'No active Salvage Collection Zones configured.') + else + local msg = 'Active Salvage Collection Zones:\n' .. table.concat(activeZones, '\n') + _msgGroup(group, msg) + end +end + +-- Menu: Show nearest salvage crate vectors +function CTLD:ShowNearestSalvageCrate(group) + local cfg = self.Config.SlingLoadSalvage + if not cfg or not cfg.Enabled then return end + + local unit = group:GetUnit(1) + if not unit or not unit:IsAlive() then return end + + local pos = unit:GetPointVec3() + local here = { x = pos.x, z = pos.z } + + local nearestName, nearestMeta, nearestDist = nil, nil, math.huge + for crateName, meta in pairs(CTLD._salvageCrates) do + if meta.side == self.Side then + local dx = meta.position.x - here.x + local dz = meta.position.z - here.z + local dist = math.sqrt(dx*dx + dz*dz) + if dist < nearestDist then + nearestDist = dist + nearestName = crateName + nearestMeta = meta + end + end + end + + if not nearestName then + local msg = self.Messages.slingload_salvage_no_crates or 'No active salvage crates available.' + _msgGroup(group, msg) + return + end + + local brg = _bearingDeg(here, nearestMeta.position) + local isMetric = _getPlayerIsMetric(unit) + local rng, rngU = _fmtRange(nearestDist, isMetric) + + local msg = _fmtTemplate(self.Messages.slingload_salvage_vectors, { + id = nearestName, + brg = brg, + rng = rng, + rng_u = rngU, + weight = nearestMeta.weight, + reward = nearestMeta.rewardValue, + }) + _msgGroup(group, msg) +end + +-- #endregion SlingLoadSalvage + +-- #endregion Public helpers + -- ========================= -- Return factory -- ========================= diff --git a/Moose_CTLD_Pure/Moose_CTLD_Init_DualCoalitions.lua b/Moose_CTLD_Pure/Moose_CTLD_Init_DualCoalitions.lua index e267c13..0afdd12 100644 --- a/Moose_CTLD_Pure/Moose_CTLD_Init_DualCoalitions.lua +++ b/Moose_CTLD_Pure/Moose_CTLD_Init_DualCoalitions.lua @@ -36,6 +36,7 @@ local blueCfg = { DropZones = { { name = 'BRAVO', flag = 9002, activeWhen = 0 } }, FOBZones = { { name = 'CHARLIE', flag = 9003, activeWhen = 0 } }, MASHZones = { { name = 'MASH Alpha', freq = '251.0 AM', radius = 500, flag = 9010, activeWhen = 0 } }, + SalvageDropZones = { { name = 'S1', flag = 9020, radius = 500, activeWhen = 0 } }, }, BuildRequiresGroundCrates = true, } @@ -64,6 +65,7 @@ local redCfg = { DropZones = { { name = 'ECHO', flag = 9102, activeWhen = 0 } }, FOBZones = { { name = 'FOXTROT', flag = 9103, activeWhen = 0 } }, MASHZones = { { name = 'MASH Bravo', freq = '252.0 AM', radius = 500, flag = 9111, activeWhen = 0 } }, + SalvageDropZones = { { name = 'S2', flag = 9020, radius = 500, activeWhen = 0 } }, }, BuildRequiresGroundCrates = true, } diff --git a/Moose_CTLD_Pure/Moose_CTLD_Pure.miz b/Moose_CTLD_Pure/Moose_CTLD_Pure.miz index 06dcf22..8b93d48 100644 Binary files a/Moose_CTLD_Pure/Moose_CTLD_Pure.miz and b/Moose_CTLD_Pure/Moose_CTLD_Pure.miz differ