-- ============================================================ -- DOGFIGHT ARENA v1.2 -- Phase 3 + Persistence -- A WW2 PVP scoring mission for DCS World + MOOSE -- -- LOAD ORDER (via script_loader.lua): -- 1. Moose_.lua -- 2. dogfight_arena.lua -- -- ME SLOT NAMES: -- Blue: TDM_Arena_P51D_1–4, TDM_Arena_SpitIX_1–4, TDM_Arena_P47D_1–4, TDM_Arena_F4U_1–4, TDM_Arena_La7_1–4 -- Red: TDM_Arena_Bf109K_1–4, TDM_Arena_FW190D_1–4, TDM_Arena_FW190A_1–4 -- -- AI FILL TEMPLATES (Late Activation ON, no player slot): -- Blue: Arena_AI_Blue_P51_1, Arena_AI_Blue_Spitfire_1, Arena_AI_Blue_P47_1, Arena_AI_Blue_La7_1 -- Red: Arena_AI_Red_109K_1, Arena_AI_Red_190D_1, Arena_AI_Red_190A_1 -- -- AI SPAWN ZONES (ME trigger zones over each side's home airfield): -- Arena_AI_Spawn_Blue -- above Blue airfield -- Arena_AI_Spawn_Red -- above Red airfield -- -- PERSISTENT DATA FILES (written to scripts folder): -- leaderboard.json -- cumulative pilot stats, updated each round -- rounds.json -- per-round history log, appended each round -- -- CHANGES v0.3: -- - Welcome message shows on player spawn, not mission start -- - F10 scoreboard menu is per-group (only arena players see it) -- - Updated slot names to aircraft-based convention -- CHANGES v0.4: -- - Training area kills no longer score in the arena -- - Arena kill messages scoped to arena players only -- - Training kill messages sent to killer's group only -- CHANGES v0.5: -- - Periodic scoreboard broadcast removed; scoreboard on-demand via F10 only -- CHANGES v0.9: -- - All messages simplified, box borders and emojis removed -- CHANGES v1.0: -- - Phase 3: AI fill spawning for side balancing -- - Target per side = max(opposing total, 1) — always at least 1 enemy -- - AI skill and aircraft type randomised each spawn -- - AI stays until killed when players join -- - F10 menu: Clear AI command added -- - S_EVENT_PLAYER_LEAVE_UNIT handled — removes from active headcount -- CHANGES v1.2: -- - Replaced S_EVENT_PLAYER_ENTER_UNIT with polling scheduler (2s interval) -- - Fixes menu not appearing on dedicated servers where DCSServerBot / gRPC -- hooks intercept the event before it reaches ArenaEvents:onEvent -- - Player exit and airborne penalty also handled by polling -- - HIT, KILL, PILOT_DEAD, EJECTION, LEAVE, LAND remain event-driven -- ============================================================ -- ============================================================ -- CONFIG -- Edit this section when porting to a new map -- ============================================================ CONFIG = { -- [ Arena boundary zone ] ARENA_ZONE_NAME = "Arena_Zone", -- [ Boundary enforcement ] BOUNDARY_COUNTDOWN = 20, BOUNDARY_CHECK_INT = 1, -- [ Scoring ] POINTS_KILL = 3, POINTS_ASSIST = 1, -- [ Landing Bonus ] LANDING_BONUS = { 2, 5, 9, 14 }, LANDING_BONUS_MAX = 14, -- [ Win Conditions ] WIN_SCORE = 50, TIME_LIMIT_MIN = 30, -- [ Broadcasts ] BROADCAST_INTERVAL = 120, MSG_DURATION = 15, -- [ Teams ] TEAM_NAME = { [coalition.side.BLUE] = "Allies", [coalition.side.RED] = "Axis", }, -- [ Arena slot names ] ARENA_SLOTS = { [coalition.side.BLUE] = { "TDM_Arena_P51D_1", "TDM_Arena_P51D_2", "TDM_Arena_P51D_3", "TDM_Arena_P51D_4", "TDM_Arena_SpitIX_1","TDM_Arena_SpitIX_2","TDM_Arena_SpitIX_3","TDM_Arena_SpitIX_4", "TDM_Arena_P47D_1", "TDM_Arena_P47D_2", "TDM_Arena_P47D_3", "TDM_Arena_P47D_4", "TDM_Arena_F4U_1", "TDM_Arena_F4U_2", "TDM_Arena_F4U_3", "TDM_Arena_F4U_4", "TDM_Arena_La7_1", "TDM_Arena_La7_2", "TDM_Arena_La7_3", "TDM_Arena_La7_4", }, [coalition.side.RED] = { "TDM_Arena_Bf109K_1", "TDM_Arena_Bf109K_2", "TDM_Arena_Bf109K_3", "TDM_Arena_Bf109K_4", "TDM_Arena_FW190D_1", "TDM_Arena_FW190D_2", "TDM_Arena_FW190D_3", "TDM_Arena_FW190D_4", "TDM_Arena_FW190A_1", "TDM_Arena_FW190A_2", "TDM_Arena_FW190A_3", "TDM_Arena_FW190A_4", }, }, -- [ AI Fill — Phase 3 ] -- AI spawns to balance sides. Target for each side = max(opposing human count, 1). -- If both sides are equal and non-zero, no AI spawns. -- AI stays until killed — no forced despawn when players join. AI_CHECK_INTERVAL = 30, -- seconds between balance checks AI_SPAWN_ALT_M = 2438, -- spawn altitude in meters (~8,000ft) -- AI fill template pools — one is picked at random each spawn (Late Activation ON) AI_TEMPLATES = { [coalition.side.BLUE] = { "Arena_AI_Blue_P51_1", "Arena_AI_Blue_Spitfire_1", "Arena_AI_Blue_P47_1", "Arena_AI_Blue_La7_1", }, [coalition.side.RED] = { "Arena_AI_Red_109K_1", "Arena_AI_Red_190D_1", "Arena_AI_Red_190A_1", }, }, -- ME trigger zones over each side's home airfield — AI spawns in the air above these AI_SPAWN_ZONES = { [coalition.side.BLUE] = "Arena_AI_Spawn_Blue", [coalition.side.RED] = "Arena_AI_Spawn_Red", }, -- Skill pool — one is picked at random each spawn AI_SKILL_POOL = { "Average", "Good", "Excellent" }, -- [ Persistence — leaderboard and round history ] LEADERBOARD_PATH = lfs.writedir() .. [[Missions\Scripts\Jaxx\leaderboard.json]], ROUNDS_PATH = lfs.writedir() .. [[Missions\Scripts\Jaxx\rounds.json]], } -- Build a fast lookup set for arena slots local arenaSlotSet = {} for _, slots in pairs(CONFIG.ARENA_SLOTS) do for _, name in ipairs(slots) do arenaSlotSet[name] = true end end -- ============================================================ -- STATE -- ============================================================ State = { gameActive = true, roundNumber = 1, startTime = timer.getTime(), teamScore = { [coalition.side.BLUE] = 0, [coalition.side.RED] = 0, }, -- players[unitName] = { ... } players = {}, -- damageLog[victimUnitName] = { [attackerUnitName] = true, ... } damageLog = {}, -- aiGroups[side] = list of MOOSE GROUP objects for active AI fill aircraft aiGroups = { [coalition.side.BLUE] = {}, [coalition.side.RED] = {}, }, } -- Cumulative pilot stats — loaded from disk at startup, saved each round -- Keyed by playerName (persists across slots and rounds) Leaderboard = { players = {}, -- [playerName] = { kills, deaths, assists, score, games } } -- ============================================================ -- UTILITY FUNCTIONS -- ============================================================ local function getOrCreatePlayer(unit) if not unit or not unit:isExist() then return nil end local unitName = unit:getName() local playerName = unit:getPlayerName() if not playerName then return nil end if not State.players[unitName] then State.players[unitName] = { playerName = playerName, coalition = unit:getCoalition(), kills = 0, deaths = 0, assists = 0, score = 0, flightKills = 0, active = true, -- false when player has left the slot menu = nil, } env.info("[ARENA] Registered player: " .. playerName .. " (" .. CONFIG.TEAM_NAME[unit:getCoalition()] .. ")") else -- Player re-entered an existing slot — reactivate, preserve score State.players[unitName].active = true State.players[unitName].coalition = unit:getCoalition() env.info("[ARENA] Player re-entered slot: " .. playerName) end return State.players[unitName] end local function addScore(playerEntry, points, reason) playerEntry.score = playerEntry.score + points local teamSide = playerEntry.coalition State.teamScore[teamSide] = State.teamScore[teamSide] + points env.info(string.format("[ARENA] %s scored %+d (%s) | Individual: %d | Team %s: %d", playerEntry.playerName, points, reason, playerEntry.score, CONFIG.TEAM_NAME[teamSide], State.teamScore[teamSide])) end local function getLandingBonus(kills) if kills <= 0 then return 0 end local bonus = CONFIG.LANDING_BONUS[kills] if bonus then return bonus end return CONFIG.LANDING_BONUS_MAX end -- Send a message only to groups that currently have active arena players. -- Deduplicates by group ID so players in the same slot group don't get it twice. local function broadcastToArenaPlayers(text, duration) local sentGroups = {} for unitName, pData in pairs(State.players) do if pData.active then local unit = Unit.getByName(unitName) if unit and unit:isExist() then local grp = unit:getGroup() if grp then local gid = grp:getID() if not sentGroups[gid] then trigger.action.outTextForGroup(gid, text, duration) sentGroups[gid] = true end end end end end end -- ============================================================ -- AI FILL FUNCTIONS — Phase 3 -- ============================================================ -- Count human players currently in a slot (active) on a given side local function countArenaSidePlayers(side) local count = 0 for unitName, pData in pairs(State.players) do if pData.coalition == side and pData.active then local u = Unit.getByName(unitName) if u and u:isExist() then count = count + 1 end end end return count end -- Prune dead AI refs and return count of alive AI for a side local function countAliveAI(side) local alive = {} for _, grp in ipairs(State.aiGroups[side]) do if grp and grp:IsAlive() then table.insert(alive, grp) end end State.aiGroups[side] = alive return #alive end -- Destroy all tracked AI for a side local function clearArenaAI(side) for _, grp in ipairs(State.aiGroups[side]) do if grp and grp:IsAlive() then grp:Destroy() end end State.aiGroups[side] = {} env.info("[ARENA] AI fill cleared for " .. CONFIG.TEAM_NAME[side]) end -- Spawn one AI fill aircraft for a side in the air above its home airfield zone local function spawnArenaAI(side) local pool = CONFIG.AI_TEMPLATES[side] if not pool or #pool == 0 then return end -- Pick random aircraft type and skill local templateName = pool[math.random(#pool)] local skill = CONFIG.AI_SKILL_POOL[math.random(#CONFIG.AI_SKILL_POOL)] local zoneName = CONFIG.AI_SPAWN_ZONES[side] if not zoneName then env.error("[ARENA] AI_SPAWN_ZONES not configured for side " .. tostring(side)) return end local zone = ZONE:New(zoneName) if not zone then env.error("[ARENA] AI spawn zone not found: " .. zoneName) return end local centre = zone:GetVec3() local spawnPos = { x = centre.x, y = CONFIG.AI_SPAWN_ALT_M, z = centre.z, } local grp = SPAWN:New(templateName) :InitSkill(skill) :SpawnFromVec3(spawnPos) if grp then table.insert(State.aiGroups[side], grp) SCHEDULER:New(nil, function() local dcsGrp = Group.getByName(grp:GetName()) if not dcsGrp then return end local ctrl = dcsGrp:getController() 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.OVERPOWER_ATTACKERS) end, {}, 1, 0) broadcastToArenaPlayers(string.format( "AI fill spawned for %s (%s / %s).", CONFIG.TEAM_NAME[side], templateName, skill), 8) env.info(string.format("[ARENA] AI fill spawned for %s — template: %s — skill: %s", CONFIG.TEAM_NAME[side], templateName, skill)) end end -- Check both sides and spawn AI to balance — target for each side is -- max(opposing total (humans + AI), 1) so there is always at least one enemy. -- AI stays until killed — no forced despawn when players join. local function checkAndFillAI() if not State.gameActive then return end local blueHumans = countArenaSidePlayers(coalition.side.BLUE) local redHumans = countArenaSidePlayers(coalition.side.RED) -- No players at all — nothing to balance yet if blueHumans == 0 and redHumans == 0 then env.info("[ARENA] AI check — no players on either side, skipping") return end local blueAI = countAliveAI(coalition.side.BLUE) local redAI = countAliveAI(coalition.side.RED) local blueTotal = blueHumans + blueAI local redTotal = redHumans + redAI env.info(string.format( "[ARENA] AI check — Blue: %dH + %dAI = %d | Red: %dH + %dAI = %d", blueHumans, blueAI, blueTotal, redHumans, redAI, redTotal)) local targets = { [coalition.side.BLUE] = math.max(redTotal, 1), [coalition.side.RED] = math.max(blueTotal, 1), } for _, side in ipairs({ coalition.side.BLUE, coalition.side.RED }) do local total = side == coalition.side.BLUE and blueTotal or redTotal local target = targets[side] env.info(string.format( "[ARENA] AI check — %s total=%d target=%d", CONFIG.TEAM_NAME[side], total, target)) if total < target then local needed = target - total env.info(string.format( "[ARENA] Balance check — %s: %d total, target %d — spawning %d fill", CONFIG.TEAM_NAME[side], total, target, needed)) for _ = 1, needed do local ok, err = pcall(spawnArenaAI, side) if not ok then env.error("[ARENA] spawnArenaAI error: " .. tostring(err)) end end end end end local function buildScoreboardText() local lines = {} local elapsed = timer.getTime() - State.startTime local remaining = (CONFIG.TIME_LIMIT_MIN * 60) - elapsed local mins = math.max(0, math.floor(remaining / 60)) local secs = math.max(0, math.floor(remaining % 60)) table.insert(lines, string.format("DOGFIGHT ARENA — Round %d", State.roundNumber)) table.insert(lines, string.format("ALLIES %d pts AXIS %d pts", State.teamScore[coalition.side.BLUE], State.teamScore[coalition.side.RED])) table.insert(lines, string.format("First to %d | %02d:%02d remaining", CONFIG.WIN_SCORE, mins, secs)) table.insert(lines, "") table.insert(lines, "PLAYER K A D PTS") table.insert(lines, "------------------------------------") local sorted = {} for _, p in pairs(State.players) do table.insert(sorted, p) end table.sort(sorted, function(a, b) return a.score > b.score end) for _, p in ipairs(sorted) do table.insert(lines, string.format("%-20s %2d %2d %2d %3d", p.playerName, p.kills, p.assists, p.deaths, p.score)) end if #sorted == 0 then table.insert(lines, "No players yet.") end return table.concat(lines, "\n") end local function broadcastScoreboard() if not State.gameActive then return end broadcastToArenaPlayers(buildScoreboardText(), CONFIG.MSG_DURATION) end -- Send a message directly to a single unit's group (used for boundary warnings). local function msgToUnit(unit, text, duration) local grp = unit:getGroup() if grp then trigger.action.outTextForGroup(grp:getID(), text, duration) end end -- MOOSE zone object cached at startup so we don't re-instantiate every tick. local _arenaZone = nil local function getArenaZone() if not _arenaZone then _arenaZone = ZONE:New(CONFIG.ARENA_ZONE_NAME) if not _arenaZone then env.error("[ARENA] Trigger zone '" .. CONFIG.ARENA_ZONE_NAME .. "' not found in mission!") end end return _arenaZone end -- Returns true if a unit is inside the arena boundary zone. -- Uses direct distance math against the zone's centre + radius — no MOOSE group bridging. local function inArenaZone(unit) local zone = getArenaZone() if not zone then return true end -- fail open so players aren't wrongly destroyed local pos = unit:getPoint() -- DCS Vec3: .x = north, .z = east (map Y) local centre = zone:GetVec2() -- MOOSE Vec2: .x = north, .y = east local radius = zone:GetRadius() local dx = pos.x - centre.x local dy = pos.z - centre.y -- DCS .z maps to MOOSE Vec2 .y return (dx*dx + dy*dy) <= (radius * radius) end -- ============================================================ -- PERSISTENCE — leaderboard.json and rounds.json -- ============================================================ -- Simple JSON encoder — handles strings, numbers, booleans, and nested tables. -- DCS Lua does not expose a JSON global; this avoids that dependency entirely. local function jsonEncode(val, indent) indent = indent or 0 local t = type(val) if t == "nil" then return "null" elseif t == "boolean" then return val and "true" or "false" elseif t == "number" then return tostring(val) elseif t == "string" then return '"' .. val:gsub('\\', '\\\\'):gsub('"', '\\"'):gsub('\n', '\\n') .. '"' elseif t == "table" then -- Detect array vs object local isArray = true local maxN = 0 for k, _ in pairs(val) do if type(k) ~= "number" or k ~= math.floor(k) or k < 1 then isArray = false; break end if k > maxN then maxN = k end end if isArray and maxN == #val then local parts = {} for _, v in ipairs(val) do parts[#parts+1] = jsonEncode(v, indent+2) end return "[" .. table.concat(parts, ",") .. "]" else local parts = {} for k, v in pairs(val) do parts[#parts+1] = '"' .. tostring(k) .. '":' .. jsonEncode(v, indent+2) end return "{" .. table.concat(parts, ",") .. "}" end end return "null" end -- Minimal JSON decoder — only handles the flat structures we write ourselves. -- Uses DCS's loadstring to safely evaluate JSON-compatible Lua table syntax. local function jsonDecode(str) if not str or str == "" then return nil end -- Convert JSON to Lua table syntax local lua = str :gsub('"([^"]+)"%s*:', '["%1"]=') -- "key": -> ["key"]= :gsub('%[%s*"', '{"') -- opening array of strings :gsub('"(,?)%s*%]', '"' .. '%1}') -- closing :gsub('null', 'nil') local fn, err = load("return " .. lua) if not fn then env.error("[ARENA] jsonDecode load error: " .. tostring(err)) return nil end local ok, result = pcall(fn) if not ok then env.error("[ARENA] jsonDecode eval error: " .. tostring(result)) return nil end return result end local function loadLeaderboard() local f = io.open(CONFIG.LEADERBOARD_PATH, "r") if not f then env.info("[ARENA] No leaderboard file found — starting fresh") return end local content = f:read("*a") f:close() if content and content ~= "" then local data = jsonDecode(content) if data and data.players then Leaderboard.players = data.players local count = 0 for _ in pairs(Leaderboard.players) do count = count + 1 end env.info("[ARENA] Leaderboard loaded — " .. count .. " pilots on record") else env.error("[ARENA] Failed to parse leaderboard.json — starting fresh") end end end local function saveLeaderboard() local data = { players = Leaderboard.players, updated = os.date("%Y-%m-%d"), } local encoded = jsonEncode(data) local f = io.open(CONFIG.LEADERBOARD_PATH, "w") if not f then env.error("[ARENA] Cannot write leaderboard: " .. CONFIG.LEADERBOARD_PATH) return end f:write(encoded) f:close() env.info("[ARENA] Leaderboard saved — " .. CONFIG.LEADERBOARD_PATH) end local function accumulateToLeaderboard() for _, pData in pairs(State.players) do local name = pData.playerName if not Leaderboard.players[name] then Leaderboard.players[name] = { kills = 0, deaths = 0, assists = 0, score = 0, games = 0, } end local lb = Leaderboard.players[name] lb.kills = lb.kills + pData.kills lb.deaths = lb.deaths + pData.deaths lb.assists = lb.assists + pData.assists lb.score = lb.score + pData.score lb.games = lb.games + 1 end env.info("[ARENA] Round " .. State.roundNumber .. " accumulated to leaderboard") end local function appendRound(reason, winner) local roundData = { round = State.roundNumber, date = os.date("%Y-%m-%d"), reason = reason, winner = winner, blueScore = State.teamScore[coalition.side.BLUE], redScore = State.teamScore[coalition.side.RED], players = {}, } for _, pData in pairs(State.players) do roundData.players[pData.playerName] = { kills = pData.kills, deaths = pData.deaths, assists = pData.assists, score = pData.score, } end local rounds = { rounds = {} } local f = io.open(CONFIG.ROUNDS_PATH, "r") if f then local content = f:read("*a") f:close() if content and content ~= "" then local data = jsonDecode(content) if data and data.rounds then rounds = data end end end table.insert(rounds.rounds, roundData) local encoded = jsonEncode(rounds) local fw = io.open(CONFIG.ROUNDS_PATH, "w") if not fw then env.error("[ARENA] Cannot write rounds: " .. CONFIG.ROUNDS_PATH) return end fw:write(encoded) fw:close() env.info("[ARENA] Round " .. State.roundNumber .. " appended to " .. CONFIG.ROUNDS_PATH) end local function endRound(reason) State.gameActive = false local blueScore = State.teamScore[coalition.side.BLUE] local redScore = State.teamScore[coalition.side.RED] local winner if blueScore > redScore then winner = "Allies" elseif redScore > blueScore then winner = "Axis" else winner = "Draw" end -- Persist before resetting round state appendRound(reason, winner) accumulateToLeaderboard() saveLeaderboard() local msg = string.format( "%s\nALLIES %d pts | AXIS %d pts\nNew round starting in 20s...", reason, blueScore, redScore) trigger.action.outText(msg, 30) env.info("[ARENA] " .. reason) SCHEDULER:New(nil, function() resetGame() end, {}, 20) end local function checkWinCondition() for side, score in pairs(State.teamScore) do if score >= CONFIG.WIN_SCORE then endRound(string.format("%s WINS ROUND %d!", CONFIG.TEAM_NAME[side], State.roundNumber)) end end end function resetGame() State.roundNumber = State.roundNumber + 1 State.gameActive = true State.startTime = timer.getTime() State.teamScore = { [coalition.side.BLUE] = 0, [coalition.side.RED] = 0, } State.damageLog = {} -- Clear all AI fill on round reset — fresh fill will spawn via scheduler clearArenaAI(coalition.side.BLUE) clearArenaAI(coalition.side.RED) for _, p in pairs(State.players) do p.kills = 0 p.deaths = 0 p.assists = 0 p.score = 0 p.flightKills = 0 end local msg = string.format("Round %d — FIGHT!", State.roundNumber) trigger.action.outText(msg, 10) env.info("[ARENA] " .. msg) end -- ============================================================ -- AI FILL SCHEDULER -- ============================================================ SCHEDULER:New(nil, function() checkAndFillAI() end, {}, 10, CONFIG.AI_CHECK_INTERVAL) -- Initial delay of 10 seconds so the mission is fully running before first check --- Build per-group F10 menu for an arena player local function buildArenaMenu(unit, player) local dcsGroup = unit:getGroup() if not dcsGroup then return end local group = GROUP:FindByName(dcsGroup:getName()) if not group then return end -- Remove old menu if it exists if player.menu then player.menu:Remove() player.menu = nil end local rootMenu = MENU_GROUP:New(group, "Dogfight Arena") MENU_GROUP_COMMAND:New(group, "Show Scoreboard", rootMenu, broadcastScoreboard) MENU_GROUP_COMMAND:New(group, "Clear AI (both sides)", rootMenu, function() clearArenaAI(coalition.side.BLUE) clearArenaAI(coalition.side.RED) local u = Unit.getByName(unit:getName()) if u then local grp = u:getGroup() if grp then trigger.action.outTextForGroup(grp:getID(), "AI fill cleared for both sides.", 6) end end end) player.menu = rootMenu end -- ============================================================ -- TIME LIMIT CHECKER -- ============================================================ SCHEDULER:New(nil, function() if not State.gameActive then return end local elapsed = timer.getTime() - State.startTime if elapsed >= CONFIG.TIME_LIMIT_MIN * 60 then local blueScore = State.teamScore[coalition.side.BLUE] local redScore = State.teamScore[coalition.side.RED] if blueScore > redScore then endRound(string.format("TIME LIMIT — %s WINS ROUND %d!", CONFIG.TEAM_NAME[coalition.side.BLUE], State.roundNumber)) elseif redScore > blueScore then endRound(string.format("TIME LIMIT — %s WINS ROUND %d!", CONFIG.TEAM_NAME[coalition.side.RED], State.roundNumber)) else endRound(string.format("TIME LIMIT — ROUND %d IS A DRAW!", State.roundNumber)) end end end, {}, 60, 60) -- ============================================================ -- SLOT POLLING — replaces S_EVENT_PLAYER_ENTER_UNIT -- DCSServerBot / gRPC hooks on dedicated servers intercept -- the event before it reaches ArenaEvents:onEvent. -- Poll every 2 seconds instead. -- ============================================================ local arenaBoundaryCountdowns = {} -- declared here for use in both polling and boundary scheduler SCHEDULER:New(nil, function() for _, slots in pairs(CONFIG.ARENA_SLOTS) do for _, unitName in ipairs(slots) do local unit = Unit.getByName(unitName) if unit and unit:isExist() then local playerName = unit:getPlayerName() if playerName then -- Slot occupied by a human player if not State.players[unitName] then -- New arrival local player = getOrCreatePlayer(unit) if player then player.flightKills = 0 player.active = true buildArenaMenu(unit, player) local welcomeMsg = string.format( "Dogfight Arena — Round %d\nAllies vs Axis | First to %d points | %d min limit\nF10 > Dogfight Arena > Show Scoreboard", State.roundNumber, CONFIG.WIN_SCORE, CONFIG.TIME_LIMIT_MIN) local dcsGroup = unit:getGroup() if dcsGroup then trigger.action.outTextForGroup(dcsGroup:getID(), welcomeMsg, 20) end env.info("[ARENA] Polling detected: " .. playerName .. " in slot " .. unitName) end elseif not State.players[unitName].active then -- Player re-entered a slot they previously left local player = State.players[unitName] player.active = true player.flightKills = 0 buildArenaMenu(unit, player) env.info("[ARENA] Polling detected re-entry: " .. playerName .. " in slot " .. unitName) end else -- No player — clean up if we had an active record local player = State.players[unitName] if player and player.active then if unit:isExist() and unit:inAir() and State.gameActive then player.deaths = player.deaths + 1 player.flightKills = 0 addScore(player, -CONFIG.POINTS_KILL, "left slot while airborne") broadcastToArenaPlayers( player.playerName .. " left the slot while airborne — penalty applied.", 8) env.info("[ARENA] " .. player.playerName .. " left airborne — penalty applied") end player.active = false if player.menu then player.menu:Remove() player.menu = nil end arenaBoundaryCountdowns[unitName] = nil State.damageLog[unitName] = nil env.info("[ARENA] Polling cleanup: " .. player.playerName .. " no longer in slot " .. unitName) end end end end end end, {}, 3, 2) -- first poll after 3 seconds, then every 2 seconds -- ============================================================ -- EVENT HANDLER -- PLAYER_ENTER_UNIT removed — handled by polling above. -- HIT, KILL, PILOT_DEAD, EJECTION, PLAYER_LEAVE_UNIT, -- and LAND are retained as event-driven. -- ============================================================ ArenaEvents = {} function ArenaEvents:onEvent(event) -- -------------------------------------------------------- -- HIT EVENT -- -------------------------------------------------------- if event.id == world.event.S_EVENT_HIT then if not State.gameActive then return end local attackerUnit = event.initiator local victimUnit = event.target if not attackerUnit or not victimUnit then return end local attackerName = attackerUnit:getPlayerName() if not attackerName then return end if attackerUnit:getCoalition() == victimUnit:getCoalition() then return end local victimUnitName = victimUnit:getName() if not State.damageLog[victimUnitName] then State.damageLog[victimUnitName] = {} end State.damageLog[victimUnitName][attackerUnit:getName()] = true -- -------------------------------------------------------- -- KILL EVENT -- -------------------------------------------------------- elseif event.id == world.event.S_EVENT_KILL then if not State.gameActive then return end local killerUnit = event.initiator local victimUnit = event.target if not killerUnit or not victimUnit then return end -- Determine if killer is an arena slot player. -- Training area kills: show kill message to killer's group only, no scoring. local killerUnitName = killerUnit:getName() local killerIsArena = arenaSlotSet[killerUnitName] == true local killerSide = killerUnit:getCoalition() local victimSide = victimUnit:getCoalition() local victimUnitName = victimUnit:getName() if killerSide ~= victimSide then if killerIsArena then -- ── ARENA KILL: score + broadcast to arena players ────────── local killer = getOrCreatePlayer(killerUnit) if killer then killer.kills = killer.kills + 1 killer.flightKills = killer.flightKills + 1 addScore(killer, CONFIG.POINTS_KILL, "kill") end local damagers = State.damageLog[victimUnitName] if damagers then for attackerUnitName, _ in pairs(damagers) do if attackerUnitName ~= killerUnitName then local assistPlayer = State.players[attackerUnitName] if assistPlayer then assistPlayer.assists = assistPlayer.assists + 1 addScore(assistPlayer, CONFIG.POINTS_ASSIST, "assist") end end end end State.damageLog[victimUnitName] = nil local victimName = victimUnit:getPlayerName() or "AI" local killerName = killer and killer.playerName or "Unknown" local msg = string.format( "SPLASH ONE\n%s shot down %s\nALLIES %d | AXIS %d", killerName, victimName, State.teamScore[coalition.side.BLUE], State.teamScore[coalition.side.RED]) broadcastToArenaPlayers(msg, 8) checkWinCondition() else -- ── TRAINING KILL: message to killer's group only, no scoring ── State.damageLog[victimUnitName] = nil local killerPlayerName = killerUnit:getPlayerName() if killerPlayerName then local victimName = victimUnit:getPlayerName() or "AI" local msg = string.format( "SPLASH ONE\n%s shot down %s", killerPlayerName, victimName) local dcsGrp = killerUnit:getGroup() if dcsGrp then trigger.action.outTextForGroup(dcsGrp:getID(), msg, 8) end env.info("[TRAINING] Kill (no score): " .. killerPlayerName .. " -> " .. victimName) end end else -- ── TEAM KILL: only penalise arena players ─────────────────────── if killerIsArena then local killer = getOrCreatePlayer(killerUnit) if killer then addScore(killer, -CONFIG.POINTS_KILL, "team-kill penalty") broadcastToArenaPlayers(killer.playerName .. " — TEAM KILL!", 8) end end end -- -------------------------------------------------------- -- PILOT DEAD / EJECTED -- -------------------------------------------------------- elseif 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() if not arenaSlotSet[unitName] then return end local player = unit and getOrCreatePlayer(unit) if player then if State.gameActive then player.deaths = player.deaths + 1 player.flightKills = 0 end player.active = false -- Remove menu on death if player.menu then player.menu:Remove() player.menu = nil end env.info("[ARENA] " .. player.playerName .. " died — flight kills reset") end State.damageLog[unitName] = nil -- -------------------------------------------------------- -- PLAYER LEAVE UNIT (voluntary slot exit) -- Fires when a player disconnects or switches slots. -- Must remove from State.players so the AI balance check -- sees the correct human count on each side. -- -------------------------------------------------------- elseif event.id == world.event.S_EVENT_PLAYER_LEAVE_UNIT then local unit = event.initiator -- On dedicated servers, initiator can be nil or a stale object with no methods if not unit or type(unit.getName) ~= "function" then return end local unitName = unit:getName() if not arenaSlotSet[unitName] then return end local player = State.players[unitName] if player and player.active then -- Death penalty if left while airborne if unit:isExist() and unit:inAir() and State.gameActive then player.deaths = player.deaths + 1 player.flightKills = 0 addScore(player, -CONFIG.POINTS_KILL, "left slot while airborne") broadcastToArenaPlayers( player.playerName .. " left the slot while airborne — penalty applied.", 8) env.info("[ARENA] " .. player.playerName .. " left airborne — penalty applied") end -- Deactivate but preserve score data player.active = false if player.menu then player.menu:Remove() player.menu = nil end env.info("[ARENA] " .. player.playerName .. " left slot " .. unitName) end State.damageLog[unitName] = nil -- -------------------------------------------------------- -- LANDING -- -------------------------------------------------------- elseif event.id == world.event.S_EVENT_LAND then if not State.gameActive then return end local unit = event.initiator -- Only process landing bonuses for arena slot players if not unit or not arenaSlotSet[unit:getName()] then return end local player = getOrCreatePlayer(unit) if player then if player.flightKills > 0 then local bonus = getLandingBonus(player.flightKills) local kills = player.flightKills player.flightKills = 0 addScore(player, bonus, string.format("landing bonus (%d kills)", kills)) local msg = string.format( "Landing bonus — %s landed with %d kill%s. +%d points.", player.playerName, kills, kills > 1 and "s" or "", bonus) broadcastToArenaPlayers(msg, 10) end if unit then State.damageLog[unit:getName()] = nil end end end end world.addEventHandler(ArenaEvents) -- ============================================================ -- PERIODIC SCORE BROADCAST — disabled; scoreboard is on-demand via F10 menu only -- ============================================================ -- (removed — players request scoreboard via F10 > Dogfight Arena > Show Scoreboard) -- ============================================================ -- ARENA BOUNDARY ENFORCEMENT -- ============================================================ SCHEDULER:New(nil, function() for unitName, pData in pairs(State.players) do -- Skip players who are not currently in a slot if not pData.active then arenaBoundaryCountdowns[unitName] = nil else local unit = Unit.getByName(unitName) -- Clean up stale entries if not unit or not unit:isExist() then arenaBoundaryCountdowns[unitName] = nil else if inArenaZone(unit) then -- Back inside — clear any active warning if arenaBoundaryCountdowns[unitName] then arenaBoundaryCountdowns[unitName] = nil msgToUnit(unit, "Back in the arena — warning cleared.", 4) end else -- Outside zone — start or tick down countdown if not arenaBoundaryCountdowns[unitName] then arenaBoundaryCountdowns[unitName] = CONFIG.BOUNDARY_COUNTDOWN end local count = arenaBoundaryCountdowns[unitName] if count <= 0 then unit:destroy() arenaBoundaryCountdowns[unitName] = nil env.info("[ARENA] " .. unitName .. " destroyed for leaving arena boundary") else msgToUnit(unit, string.format( "Return to arena — removed in %d seconds.", count), 2) arenaBoundaryCountdowns[unitName] = count - 1 end end end end end end, {}, 1, CONFIG.BOUNDARY_CHECK_INT) -- ============================================================ -- MISSION END — save leaderboard on server stop -- ============================================================ local MissionEndHandler = {} function MissionEndHandler:onEvent(event) if event.id == world.event.S_EVENT_MISSION_END then env.info("[ARENA] Mission ending — saving leaderboard") accumulateToLeaderboard() saveLeaderboard() end end world.addEventHandler(MissionEndHandler) -- ============================================================ -- STARTUP -- ============================================================ loadLeaderboard() env.info("[ARENA] Mission script loaded successfully — v1.2")