-- ============================================================ -- TRAINING AREA v0.8 -- WW2 Dogfight Arena — Standalone Training Module -- -- LOAD ORDER (via script_loader.lua): -- 1. Moose_.lua -- 2. dogfight_arena.lua -- 3. training_area.lua ← this file -- -- ME SETUP REQUIRED: -- -- Player slots (Client, Late Activation OFF): -- Blue (per zone × 3 zones): -- BFM_P51_[z]_1, BFM_Spitfire_[z]_1, BFM_P47_[z]_1, BFM_Mosquito_[z]_1, BFM_La7_[z]_1 -- Red (per zone × 3 zones): -- BFM_109K_[z]_1, BFM_190D_[z]_1, BFM_190A_[z]_1 -- e.g. Zone 1 Blue: BFM_P51_1_1, BFM_Spitfire_1_1, BFM_P47_1_1, BFM_Mosquito_1_1, BFM_La7_1_1 -- Zone 1 Red: BFM_109K_1_1, BFM_190D_1_1, BFM_190A_1_1 -- -- AI bandit templates (Late Activation ON, no player slot): -- Axis bandits (vs Allied players): -- Training_Bandit_109K_Easy_1 / Medium_1 / Hard_1 -- Training_Bandit_190D_Easy_1 / Medium_1 / Hard_1 -- Training_Bandit_190A_Easy_1 / Medium_1 / Hard_1 -- Allied bandits (vs Axis players): -- Training_Bandit_P51_Easy_1 / Medium_1 / Hard_1 -- Training_Bandit_Spitfire_Easy_1 / Medium_1 / Hard_1 -- Training_Bandit_P47_Easy_1 / Medium_1 / Hard_1 -- Training_Bandit_La7_Easy_1 / Medium_1 / Hard_1 -- (21 total AI groups — all Late Activation ON, 10,000ft, 300kts) -- (Mosquito omitted from bandit pool — not a BFM opponent) -- -- Trigger zones (circular, 20nm = ~37040m radius): -- Training_Zone_1, Training_Zone_2, Training_Zone_3 -- -- CHANGES v0.8: -- - Replaced S_EVENT_PLAYER_ENTER_UNIT with polling scheduler (2s interval) -- - Fixes menu not appearing on dedicated servers where the event is -- intercepted by DCSServerBot / gRPC hooks before reaching our handler -- - Player exit / cleanup also handled by polling (slot empty = deregister) -- - PILOT_DEAD and EJECTION events retained for immediate death response -- ============================================================ -- ============================================================ -- CONFIG -- ============================================================ TRAINING_CONFIG = { ZONE_NAMES = { "Training_Zone_1", "Training_Zone_2", "Training_Zone_3", }, -- Radius in meters (20nm = ~37040m) ZONE_RADIUS_M = 37040, -- Boundary warning countdown in seconds BOUNDARY_COUNTDOWN = 20, -- How often to check boundary (seconds) BOUNDARY_CHECK_INT = 1, -- Max bandits per zone MAX_BANDITS = 2, -- Bandit intercept speed in m/s for head-on merge route (154 = ~300kts) BANDIT_INTERCEPT_SPEED_MS = 154, -- How far past the player the bandit's waypoint extends (meters) -- Ensures the AI commits through the merge rather than pulling off early BANDIT_OVERSHOOT_M = 3000, -- Eject warning duration before forced removal (seconds) EJECT_WARNING_SEC = 10, -- AI skill levels per difficulty DIFFICULTY = { Easy = { skill = "Average" }, Medium = { skill = "Good" }, Hard = { skill = "Excellent" }, }, -- Ordered difficulty list (controls menu order) DIFFICULTY_ORDER = { "Easy", "Medium", "Hard" }, -- Bandit templates — outer key is the PLAYER's coalition -- Inner key is aircraft type key, then difficulty -- Allied player (Blue) → fights Axis bandits -- Axis player (Red) → fights Allied bandits BANDIT_TEMPLATES = { [coalition.side.BLUE] = { ["109K"] = { Easy = "Training_Bandit_109K_Easy_1", Medium = "Training_Bandit_109K_Medium_1", Hard = "Training_Bandit_109K_Hard_1", }, ["190D"] = { Easy = "Training_Bandit_190D_Easy_1", Medium = "Training_Bandit_190D_Medium_1", Hard = "Training_Bandit_190D_Hard_1", }, ["190A"] = { Easy = "Training_Bandit_190A_Easy_1", Medium = "Training_Bandit_190A_Medium_1", Hard = "Training_Bandit_190A_Hard_1", }, }, [coalition.side.RED] = { ["P51"] = { Easy = "Training_Bandit_P51_Easy_1", Medium = "Training_Bandit_P51_Medium_1", Hard = "Training_Bandit_P51_Hard_1", }, ["Spitfire"] = { Easy = "Training_Bandit_Spitfire_Easy_1", Medium = "Training_Bandit_Spitfire_Medium_1", Hard = "Training_Bandit_Spitfire_Hard_1", }, ["P47"] = { Easy = "Training_Bandit_P47_Easy_1", Medium = "Training_Bandit_P47_Medium_1", Hard = "Training_Bandit_P47_Hard_1", }, ["La7"] = { Easy = "Training_Bandit_La7_Easy_1", Medium = "Training_Bandit_La7_Medium_1", Hard = "Training_Bandit_La7_Hard_1", }, }, }, -- Ordered aircraft type list per coalition (controls menu order) BANDIT_TYPE_ORDER = { [coalition.side.BLUE] = { "109K", "190D", "190A" }, [coalition.side.RED] = { "P51", "Spitfire", "P47", "La7" }, }, -- Human-readable labels for F10 menu BANDIT_DISPLAY_NAMES = { ["109K"] = "Bf 109 K-4", ["190D"] = "Fw 190 D-9", ["190A"] = "Fw 190 A-8", ["P51"] = "P-51D Mustang", ["Spitfire"] = "Spitfire Mk IX", ["P47"] = "P-47D Thunderbolt", ["La7"] = "La-7", }, -- Player slot names per zone and coalition -- Multiple slots supported per side — all map to same zoneIdx + side SLOT_NAMES = { [1] = { [coalition.side.BLUE] = { "BFM_P51_1_1", "BFM_Spitfire_1_1", "BFM_P47_1_1", "BFM_Mosquito_1_1", "BFM_La7_1_1", }, [coalition.side.RED] = { "BFM_109K_1_1", "BFM_190D_1_1", "BFM_190A_1_1", }, }, [2] = { [coalition.side.BLUE] = { "BFM_P51_2_1", "BFM_Spitfire_2_1", "BFM_P47_2_1", "BFM_Mosquito_2_1", "BFM_La7_2_1", }, [coalition.side.RED] = { "BFM_109K_2_1", "BFM_190D_2_1", "BFM_190A_2_1", }, }, [3] = { [coalition.side.BLUE] = { "BFM_P51_3_1", "BFM_Spitfire_3_1", "BFM_P47_3_1", "BFM_Mosquito_3_1", "BFM_La7_3_1", }, [coalition.side.RED] = { "BFM_109K_3_1", "BFM_190D_3_1", "BFM_190A_3_1", }, }, }, TEAM_NAME = { [coalition.side.BLUE] = "Allies", [coalition.side.RED] = "Axis", }, } -- Build fast lookup: slotName → { zoneIdx, side } local slotLookup = {} for zoneIdx, sides in pairs(TRAINING_CONFIG.SLOT_NAMES) do for side, slots in pairs(sides) do for _, slotName in ipairs(slots) do slotLookup[slotName] = { zoneIdx = zoneIdx, side = side } end end end -- ============================================================ -- STATE -- ============================================================ TrainingState = { -- zones[i] = { -- lockedBy : string or nil -- lockedSide : number or nil -- bandits : table of MOOSE GROUP objects -- players : { [unitName] = { playerName, coalition, menu } } -- } zones = {}, } for i = 1, #TRAINING_CONFIG.ZONE_NAMES do TrainingState.zones[i] = { lockedBy = nil, lockedSide = nil, bandits = {}, players = {}, } end -- ============================================================ -- UTILITY FUNCTIONS -- ============================================================ local function getZoneForUnit(unit) if not unit or not unit:isExist() then return nil end local pos = unit:getPoint() for i, zoneName in ipairs(TRAINING_CONFIG.ZONE_NAMES) do local zone = ZONE:New(zoneName) if zone then local zp = zone:GetVec3() local dx = pos.x - zp.x local dz = pos.z - zp.z if math.sqrt(dx*dx + dz*dz) <= TRAINING_CONFIG.ZONE_RADIUS_M then return i end end end return nil end local function countBandits(zoneIdx) local zone = TrainingState.zones[zoneIdx] local alive = {} for _, grp in ipairs(zone.bandits) do if grp and grp:IsAlive() then table.insert(alive, grp) end end zone.bandits = alive return #alive end local function getBanditSpawnPoint(zoneIdx) local zone = ZONE:New(TRAINING_CONFIG.ZONE_NAMES[zoneIdx]) local center = zone:GetVec3() return { x = center.x, y = center.y + 3048, z = center.z } -- 10000ft end local function msgToUnit(unit, text, duration) if unit and unit:isExist() then local dcsGroup = unit:getGroup() if dcsGroup then trigger.action.outTextForGroup(dcsGroup:getID(), text, duration or 10) end end end local function releaseZoneLock(zoneIdx) local zone = TrainingState.zones[zoneIdx] if zone.lockedBy then env.info("[TRAINING] Zone " .. zoneIdx .. " lock released (held by " .. zone.lockedBy .. ")") zone.lockedBy = nil zone.lockedSide = nil end end local function clearBandits(zoneIdx) for _, grp in ipairs(TrainingState.zones[zoneIdx].bandits) do if grp and grp:IsAlive() then grp:Destroy() end end TrainingState.zones[zoneIdx].bandits = {} env.info("[TRAINING] Bandits cleared in Zone " .. zoneIdx) end local function routeBanditIntercept(groupName, playerPos, spawnPos) -- NOTE on DCS waypoint coordinate format — differs from Vec3: -- waypoint.x = map X (same as Vec3.x) -- waypoint.y = map Z (same as Vec3.z — NOT altitude!) -- waypoint.alt = altitude in meters (same as Vec3.y) -- Using wrong fields produces a silent failure and the AI falls back -- to default template behaviour (orbit/turn away). local dx = playerPos.x - spawnPos.x local dz = playerPos.z - spawnPos.z local dist = math.sqrt(dx * dx + dz * dz) local ext = TRAINING_CONFIG.BANDIT_OVERSHOOT_M local spd = TRAINING_CONFIG.BANDIT_INTERCEPT_SPEED_MS local alt = spawnPos.y -- altitude in meters (Vec3.y = altitude) -- Extend the target point past the player so the AI commits through -- the merge rather than starting a turn as it reaches the player local targetX = playerPos.x + (dist > 0 and (dx / dist) * ext or 0) local targetZ = playerPos.z + (dist > 0 and (dz / dist) * ext or 0) local task = { id = "Mission", params = { route = { points = { [1] = { x = spawnPos.x, y = spawnPos.z, -- waypoint y = Vec3 z alt = alt, alt_type = "BARO", speed = spd, type = "Turning Point", action = "Turning Point", task = { id = "ComboTask", params = { tasks = {} } }, }, [2] = { x = targetX, y = targetZ, -- waypoint y = Vec3 z alt = alt, alt_type = "BARO", speed = spd, type = "Turning Point", action = "Turning Point", task = { id = "ComboTask", params = { tasks = {} } }, }, } } } } -- Delay 1 second — template tasks from the ME group fire on spawn and -- would override an immediate setTask call SCHEDULER:New(nil, function() local dcsGroup = Group.getByName(groupName) if not dcsGroup then env.info("[TRAINING] routeBanditIntercept: group gone before route applied") return end local ctrl = dcsGroup:getController() ctrl:setTask(task) ctrl:setOption(AI.Option.Air.id.ROE, AI.Option.Air.val.ROE.WEAPON_FREE) ctrl:setOption(AI.Option.Air.id.REACTION_ON_THREAT, AI.Option.Air.val.REACTION_ON_THREAT.ALLOW_ABORT_MISSION) env.info("[TRAINING] Bandit intercept route set — group: " .. groupName) end, {}, 1, 0) end local function spawnBandit(zoneIdx, playerUnit, difficulty, aircraftKey) local side = playerUnit:getCoalition() local templates = TRAINING_CONFIG.BANDIT_TEMPLATES[side] if not templates then return end local aircraftTemplates = templates[aircraftKey] if not aircraftTemplates then env.info("[TRAINING] No template found for aircraftKey: " .. tostring(aircraftKey)) return end local templateName = aircraftTemplates[difficulty] if not templateName then env.info("[TRAINING] No template found for difficulty: " .. tostring(difficulty)) return end if countBandits(zoneIdx) >= TRAINING_CONFIG.MAX_BANDITS then msgToUnit(playerUnit, "⚠ Max bandits reached — clear some first.", 8) return end local spawnPos = getBanditSpawnPoint(zoneIdx) local headingDeg = side == coalition.side.BLUE and 180 or 360 local displayName = TRAINING_CONFIG.BANDIT_DISPLAY_NAMES[aircraftKey] or aircraftKey local banditGroup = SPAWN:New(templateName) :InitSkill(TRAINING_CONFIG.DIFFICULTY[difficulty].skill) :InitHeading(headingDeg) :SpawnFromVec3(spawnPos) if banditGroup then table.insert(TrainingState.zones[zoneIdx].bandits, banditGroup) -- Snapshot player position now — playerUnit ref may be stale inside the scheduler local playerPosSS = playerUnit:getPoint() routeBanditIntercept(banditGroup:GetName(), playerPosSS, spawnPos) msgToUnit(playerUnit, string.format( "✈ Bandit inbound — %s / %s\nHead-on merge. Good hunting.", displayName, difficulty), 10) env.info(string.format("[TRAINING] Spawned %s %s bandit in Zone %d", difficulty, aircraftKey, zoneIdx)) end end -- ============================================================ -- BOUNDARY ENFORCEMENT -- ============================================================ local boundaryCountdowns = {} SCHEDULER:New(nil, function() for zoneIdx, zoneState in pairs(TrainingState.zones) do for unitName, pData in pairs(zoneState.players) do local unit = Unit.getByName(unitName) if not unit or not unit:isExist() then zoneState.players[unitName] = nil boundaryCountdowns[unitName] = nil else local inZone = (getZoneForUnit(unit) == zoneIdx) if not inZone then if not boundaryCountdowns[unitName] then boundaryCountdowns[unitName] = TRAINING_CONFIG.BOUNDARY_COUNTDOWN end local count = boundaryCountdowns[unitName] if count <= 0 then unit:destroy() boundaryCountdowns[unitName] = nil env.info("[TRAINING] " .. pData.playerName .. " removed for leaving Zone " .. zoneIdx) else msgToUnit(unit, string.format( "⚠ Return to zone — removed in %d seconds.", count), 2) boundaryCountdowns[unitName] = count - 1 end else if boundaryCountdowns[unitName] then boundaryCountdowns[unitName] = nil msgToUnit(unit, "✓ Back in zone.", 4) end end end end end end, {}, 1, TRAINING_CONFIG.BOUNDARY_CHECK_INT) -- ============================================================ -- EJECT WARNING -- ============================================================ local ejectTimers = {} local function startEjectWarning(unit, zoneIdx) local unitName = unit:getName() ejectTimers[unitName] = TRAINING_CONFIG.EJECT_WARNING_SEC SCHEDULER:New(nil, function() local count = ejectTimers[unitName] if not count then return end local u = Unit.getByName(unitName) if not u or not u:isExist() then ejectTimers[unitName] = nil return end if count <= 0 then u:destroy() ejectTimers[unitName] = nil else msgToUnit(u, string.format( "🔒 Zone locked (PvE) — ejected in %d seconds.\nPlease select another zone.", count), 2) ejectTimers[unitName] = count - 1 end end, {}, 1, 1) end -- ============================================================ -- PER-GROUP MENU BUILDER -- Structure: F10 > BFM Zone [N] -- > Spawn Bandit -- > Easy > Bf 109 K-4 / Fw 190 D-9 / Fw 190 A-8 -- > Medium > ... -- > Hard > ... -- > Lock Zone (PvE) -- > Unlock Zone -- > Clear Bandits -- ============================================================ local function buildTrainingMenu(unit, zoneIdx) local dcsGroup = unit:getGroup() if not dcsGroup then return nil end local group = GROUP:FindByName(dcsGroup:getName()) if not group then return nil end local side = unit:getCoalition() local zoneLabel = "BFM Zone " .. zoneIdx local rootMenu = MENU_GROUP:New(group, zoneLabel) -- [ Spawn Bandit ] — nested: difficulty > aircraft type local spawnMenu = MENU_GROUP:New(group, "Spawn Bandit", rootMenu) local typeOrder = TRAINING_CONFIG.BANDIT_TYPE_ORDER[side] for _, diff in ipairs(TRAINING_CONFIG.DIFFICULTY_ORDER) do local diffMenu = MENU_GROUP:New(group, diff, spawnMenu) for _, acKey in ipairs(typeOrder) do local capturedDiff = diff local capturedZone = zoneIdx local capturedAcKey = acKey local displayName = TRAINING_CONFIG.BANDIT_DISPLAY_NAMES[acKey] or acKey MENU_GROUP_COMMAND:New(group, displayName, diffMenu, function() local u = Unit.getByName(unit:getName()) if u then spawnBandit(capturedZone, u, capturedDiff, capturedAcKey) end end) end end -- [ Lock Zone ] MENU_GROUP_COMMAND:New(group, "Lock Zone (PvE)", rootMenu, function() local u = Unit.getByName(unit:getName()) if not u then return end local zone = TrainingState.zones[zoneIdx] local playerName = u:getPlayerName() if not playerName then return end zone.lockedBy = playerName zone.lockedSide = u:getCoalition() msgToUnit(u, string.format("🔒 Zone %d locked (PvE) — opposing players will be ejected.", zoneIdx), 8) env.info("[TRAINING] Zone " .. zoneIdx .. " locked by " .. playerName) end) -- [ Unlock Zone ] MENU_GROUP_COMMAND:New(group, "Unlock Zone", rootMenu, function() releaseZoneLock(zoneIdx) local u = Unit.getByName(unit:getName()) if u then msgToUnit(u, string.format("🔓 Zone %d unlocked.", zoneIdx), 6) end end) -- [ Clear Bandits ] MENU_GROUP_COMMAND:New(group, "Clear Bandits", rootMenu, function() clearBandits(zoneIdx) local u = Unit.getByName(unit:getName()) if u then msgToUnit(u, string.format("✓ Zone %d bandits cleared.", zoneIdx), 6) end end) return rootMenu end -- ============================================================ -- SLOT POLLING — replaces S_EVENT_PLAYER_ENTER_UNIT -- On dedicated servers with server bots / gRPC hooks, -- S_EVENT_PLAYER_ENTER_UNIT is unreliable. Instead, poll -- every 2 seconds to detect who is in which slots. -- ============================================================ SCHEDULER:New(nil, function() for unitName, slotInfo in pairs(slotLookup) do local unit = Unit.getByName(unitName) local zoneIdx = slotInfo.zoneIdx local zoneState = TrainingState.zones[zoneIdx] if unit and unit:isExist() then local playerName = unit:getPlayerName() if playerName then -- Slot is occupied by a human player if not zoneState.players[unitName] then -- New arrival — register and build menu local side = unit:getCoalition() env.info("[TRAINING] Polling detected: " .. playerName .. " in slot " .. unitName .. " (Zone " .. zoneIdx .. ")") -- Check if opposing side has this zone locked local oppSide = side == coalition.side.BLUE and coalition.side.RED or coalition.side.BLUE if zoneState.lockedBy and zoneState.lockedSide == oppSide then msgToUnit(unit, string.format( "🔒 Zone %d is locked by %s (PvE).\nYou will be ejected in %d seconds — select another zone.", zoneIdx, zoneState.lockedBy, TRAINING_CONFIG.EJECT_WARNING_SEC), 12) startEjectWarning(unit, zoneIdx) else local menu = buildTrainingMenu(unit, zoneIdx) zoneState.players[unitName] = { playerName = playerName, coalition = side, menu = menu, } msgToUnit(unit, string.format( "✈ BFM Zone %d\nF10 > BFM Zone %d to spawn bandits.\nMax 2 bandits | 20nm boundary.", zoneIdx, zoneIdx), 15) end end else -- Unit exists but no player — AI controlled or unoccupied -- Clean up any stale player entry (e.g. player left without dying) if zoneState.players[unitName] then local pData = zoneState.players[unitName] if zoneState.lockedBy == pData.playerName then releaseZoneLock(zoneIdx) end if pData.menu then pData.menu:Remove() end zoneState.players[unitName] = nil boundaryCountdowns[unitName] = nil ejectTimers[unitName] = nil env.info("[TRAINING] Polling cleanup: " .. pData.playerName .. " no longer in slot " .. unitName) end end else -- Unit doesn't exist — clean up if we had a record if zoneState.players[unitName] then local pData = zoneState.players[unitName] if zoneState.lockedBy == pData.playerName then releaseZoneLock(zoneIdx) end if pData.menu then pData.menu:Remove() end zoneState.players[unitName] = nil boundaryCountdowns[unitName] = nil ejectTimers[unitName] = nil env.info("[TRAINING] Polling cleanup: slot " .. unitName .. " is empty") end end end end, {}, 3, 2) -- first poll after 3 seconds, then every 2 seconds -- ============================================================ -- EVENT HANDLER (PILOT_DEAD and EJECTION only) -- Player entry is now handled by polling above. -- Death and ejection are kept event-driven for immediate response. -- ============================================================ TrainingEvents = {} function TrainingEvents:onEvent(event) if event.id == world.event.S_EVENT_PILOT_DEAD or event.id == world.event.S_EVENT_EJECTION then local unit = event.initiator if not unit then return end local unitName = unit:getName() local slotInfo = slotLookup[unitName] if not slotInfo then return end local zoneIdx = slotInfo.zoneIdx local zoneState = TrainingState.zones[zoneIdx] local pData = zoneState.players[unitName] if pData and zoneState.lockedBy == pData.playerName then releaseZoneLock(zoneIdx) end if pData and pData.menu then pData.menu:Remove() end zoneState.players[unitName] = nil boundaryCountdowns[unitName] = nil ejectTimers[unitName] = nil end end world.addEventHandler(TrainingEvents) -- ============================================================ -- STARTUP -- ============================================================ env.info("[TRAINING] Training Area script loaded successfully — v0.8 — slot detection via polling (2s interval), no S_EVENT_PLAYER_ENTER_UNIT dependency")