diff --git a/Moose_DualCoalitionZoneCapture.lua b/Moose_DualCoalitionZoneCapture.lua index 7548813..723e366 100644 --- a/Moose_DualCoalitionZoneCapture.lua +++ b/Moose_DualCoalitionZoneCapture.lua @@ -38,35 +38,6 @@ local MESSAGE_CONFIG = { GARBAGE_COLLECTION_FREQUENCY = 600 -- Lua garbage collection cadence (seconds) - helps prevent memory buildup } --- ========================================== --- ZONE COLOR CONFIGURATION (Centralized) --- ========================================== --- Colors are in RGB format: {Red, Green, Blue} where each value is 0.0 to 1.0 -local ZONE_COLORS = { - -- Blue coalition zones - BLUE_CAPTURED = {0, 0, 1}, -- Blue (owned by Blue) - BLUE_ATTACKED = {0, 1, 1}, -- Cyan (owned by Blue, under attack) - - -- Red coalition zones - RED_CAPTURED = {1, 0, 0}, -- Red (owned by Red) - RED_ATTACKED = {1, 0.5, 0}, -- Orange (owned by Red, under attack) - - -- Neutral/Empty zones - EMPTY = {0, 1, 0} -- Green (no owner) -} - --- ========================================== --- COALITION TITLES CONFIGURATION --- ========================================== --- Mission makers: Customize the display names for each coalition --- These will be used in messages, mission names, and UI elements -local COALITION_TITLES = { - BLUE = "USA", -- Display name for Blue coalition (e.g., "USA", "NATO", "Allied Forces") - RED = "Russia", -- Display name for Red coalition (e.g., "Russia", "Germany", "Axis Powers") - BLUE_OPERATION = "Operation Polar Shield", -- Name of Blue coalition's operation - RED_OPERATION = "Defend the Motherland" -- Name of Red coalition's operation -} - -- ========================================== -- ZONE CONFIGURATION -- ========================================== @@ -109,6 +80,422 @@ local ZONE_SETTINGS = { captureScore = 200 -- Points awarded for capturing a zone } +-- ========================================== +-- ZONE COLOR CONFIGURATION (Centralized) +-- ========================================== +-- Colors are in RGB format: {Red, Green, Blue} where each value is 0.0 to 1.0 +local ZONE_COLORS = { + -- Blue coalition zones + BLUE_CAPTURED = {0, 0, 1}, -- Blue (owned by Blue) + BLUE_ATTACKED = {0, 1, 1}, -- Cyan (owned by Blue, under attack) + + -- Red coalition zones + RED_CAPTURED = {1, 0, 0}, -- Red (owned by Red) + RED_ATTACKED = {1, 0.5, 0}, -- Orange (owned by Red, under attack) + + -- Neutral/Empty zones + EMPTY = {0, 1, 0} -- Green (no owner) +} + +-- ========================================== +-- COALITION TITLES CONFIGURATION +-- ========================================== +-- Mission makers: Customize the display names for each coalition +-- These will be used in messages, mission names, and UI elements +local COALITION_TITLES = { + BLUE = "USA", -- Display name for Blue coalition (e.g., "USA", "NATO", "Allied Forces") + RED = "Russia", -- Display name for Red coalition (e.g., "Russia", "Germany", "Axis Powers") + BLUE_OPERATION = "Operation Polar Shield", -- Name of Blue coalition's operation + RED_OPERATION = "Defend the Motherland" -- Name of Red coalition's operation +} + +-- ========================================== +-- LANGUAGE CONFIGURATION +-- ========================================== +local LANGUAGE_CONFIG = { + defaultLanguage = "EN", -- Default language: "EN", "DE", "FR", "ES", "RU" +} + +-- Multilingual message table +-- Each language contains all messages and menu text +local ZONE_CAPTURE_LANGUAGES = { + EN = { + -- Zone state messages + zoneGuarded = "%s is under protection of the %s", + zoneEmpty = "%s is unprotected, and can be captured!", + zoneUnderAttack = "%s is under attack by %s", + zoneWeAttacking = "We are attacking %s", + zoneCapturedByEnemy = "%s is captured by the %s, we lost it!", + zoneCapturedByUs = "We captured %s, Excellent job!", + + -- Victory messages + victoryBlue = "VICTORY! All capture zones have been secured by %s forces!\n\n%s is complete. Outstanding work!\nMission will end in 60 seconds.", + defeatBlue = "DEFEAT! All strategic positions have been lost to %s forces.\n\n%s has failed. Mission ending in 60 seconds.", + victoryRed = "VICTORY! All strategic positions secured for %s!\n\n%s forces have been repelled. Outstanding work!\nMission will end in 60 seconds.", + defeatRed = "DEFEAT! All capture zones have been lost to %s forces.\n\n%s has failed. Mission ending in 60 seconds.", + missionComplete = "Mission Complete! Congratulations on your victory!\nFinal Status: All %d strategic zones secured.", + + -- Status report messages + zoneControlReport = "ZONE CONTROL REPORT:\nBlue Coalition: %d/%d zones\nRed Coalition: %d/%d zones\nNeutral: %d/%d zones", + zoneDetails = "\nZONE DETAILS:\n", + zoneDetailLine = "• %s: %s\n", + yourProgress = "\n\nYour Progress to Victory: %d%%", + + -- Progress warning messages + blueNearVictory = "TACTICAL ALERT: %s forces control %d%% of strategic zones!\nVictory is within reach!", + redNearVictory = "WARNING: %s forces have captured %d%% of zones!\nDefend our positions!", + + -- Coalition names + coalitionBlue = "Blue Coalition", + coalitionRed = "Red Coalition", + coalitionNeutral = "Neutral", + + -- Menu items + menuZoneControl = "Zone Control", + menuStatusReport = "Get Zone Status Report", + menuVictoryProgress = "Check Victory Progress", + menuRefreshColors = "Refresh Zone Colors", + menuLanguage = "Language / Sprache / Langue", + menuEnglish = "English", + menuGerman = "Deutsch (German)", + menuFrench = "Français (French)", + menuSpanish = "Español (Spanish)", + menuRussian = "Русский (Russian)", + + -- Victory progress messages + victoryProgressTitle = "VICTORY PROGRESS: %d%%", + zonesCaptured = "Zones Captured: %d/%d", + zonesRemaining = "Remaining: %d zones", + progressComplete = "MISSION COMPLETE!", + progressAlmostThere = "ALMOST THERE!", + progressGood = "GOOD PROGRESS!", + progressKeepFighting = "KEEP FIGHTING!", + + -- System messages + zoneMarkersRefreshed = "Zone visual markers have been refreshed!", + languageChanged = "Language changed to English", + }, + + DE = { + -- Zone state messages + zoneGuarded = "%s steht unter dem Schutz der %s", + zoneEmpty = "%s ist ungeschützt und kann erobert werden!", + zoneUnderAttack = "%s wird von %s angegriffen", + zoneWeAttacking = "Wir greifen %s an", + zoneCapturedByEnemy = "%s wurde von %s erobert, wir haben es verloren!", + zoneCapturedByUs = "Wir haben %s erobert, hervorragende Arbeit!", + + -- Victory messages + victoryBlue = "SIEG! Alle Eroberungszonen wurden von %s Streitkräften gesichert!\n\n%s ist abgeschlossen. Hervorragende Arbeit!\nMission endet in 60 Sekunden.", + defeatBlue = "NIEDERLAGE! Alle strategischen Positionen wurden an %s Streitkräfte verloren.\n\n%s ist gescheitert. Mission endet in 60 Sekunden.", + victoryRed = "SIEG! Alle strategischen Positionen für %s gesichert!\n\n%s Streitkräfte wurden zurückgeschlagen. Hervorragende Arbeit!\nMission endet in 60 Sekunden.", + defeatRed = "NIEDERLAGE! Alle Eroberungszonen wurden an %s Streitkräfte verloren.\n\n%s ist gescheitert. Mission endet in 60 Sekunden.", + missionComplete = "Mission erfolgreich! Herzlichen Glückwunsch zu Ihrem Sieg!\nEndstatus: Alle %d strategischen Zonen gesichert.", + + -- Status report messages + zoneControlReport = "ZONENKONTROLLBERICHT:\nBlaue Koalition: %d/%d Zonen\nRote Koalition: %d/%d Zonen\nNeutral: %d/%d Zonen", + zoneDetails = "\nZONENDETAILS:\n", + zoneDetailLine = "• %s: %s\n", + yourProgress = "\n\nIhr Fortschritt zum Sieg: %d%%", + + -- Progress warning messages + blueNearVictory = "TAKTISCHER ALARM: %s Streitkräfte kontrollieren %d%% der strategischen Zonen!\nSieg ist in Reichweite!", + redNearVictory = "WARNUNG: %s Streitkräfte haben %d%% der Zonen erobert!\nVerteidigt unsere Positionen!", + + -- Coalition names + coalitionBlue = "Blaue Koalition", + coalitionRed = "Rote Koalition", + coalitionNeutral = "Neutral", + + -- Menu items + menuZoneControl = "Zonenkontrolle", + menuStatusReport = "Zonenstatusbericht abrufen", + menuVictoryProgress = "Siegfortschritt prüfen", + menuRefreshColors = "Zonenfarben aktualisieren", + menuLanguage = "Language / Sprache / Langue", + menuEnglish = "English", + menuGerman = "Deutsch (German)", + menuFrench = "Français (French)", + menuSpanish = "Español (Spanish)", + menuRussian = "Русский (Russian)", + + -- Victory progress messages + victoryProgressTitle = "SIEGFORTSCHRITT: %d%%", + zonesCaptured = "Zonen erobert: %d/%d", + zonesRemaining = "Verbleibend: %d Zonen", + progressComplete = "MISSION ABGESCHLOSSEN!", + progressAlmostThere = "FAST GESCHAFFT!", + progressGood = "GUTER FORTSCHRITT!", + progressKeepFighting = "WEITER KÄMPFEN!", + + -- System messages + zoneMarkersRefreshed = "Zonenmarkierungen wurden aktualisiert!", + languageChanged = "Sprache auf Deutsch geändert", + }, + + FR = { + -- Zone state messages + zoneGuarded = "%s est sous la protection de %s", + zoneEmpty = "%s est sans protection et peut être capturée !", + zoneUnderAttack = "%s est attaquée par %s", + zoneWeAttacking = "Nous attaquons %s", + zoneCapturedByEnemy = "%s est capturée par %s, nous l'avons perdue !", + zoneCapturedByUs = "Nous avons capturé %s, excellent travail !", + + -- Victory messages + victoryBlue = "VICTOIRE ! Toutes les zones de capture ont été sécurisées par les forces %s !\n\n%s est terminée. Excellent travail !\nLa mission se terminera dans 60 secondes.", + defeatBlue = "DÉFAITE ! Toutes les positions stratégiques ont été perdues face aux forces %s.\n\n%s a échoué. Mission se terminant dans 60 secondes.", + victoryRed = "VICTOIRE ! Toutes les positions stratégiques sécurisées pour %s !\n\nLes forces %s ont été repoussées. Excellent travail !\nLa mission se terminera dans 60 secondes.", + defeatRed = "DÉFAITE ! Toutes les zones de capture ont été perdues face aux forces %s.\n\n%s a échoué. Mission se terminant dans 60 secondes.", + missionComplete = "Mission accomplie ! Félicitations pour votre victoire !\nStatut final : Toutes les %d zones stratégiques sécurisées.", + + -- Status report messages + zoneControlReport = "RAPPORT DE CONTRÔLE DES ZONES :\nCoalition bleue : %d/%d zones\nCoalition rouge : %d/%d zones\nNeutre : %d/%d zones", + zoneDetails = "\nDÉTAILS DES ZONES :\n", + zoneDetailLine = "• %s : %s\n", + yourProgress = "\n\nVotre progression vers la victoire : %d%%", + + -- Progress warning messages + blueNearVictory = "ALERTE TACTIQUE : Les forces %s contrôlent %d%% des zones stratégiques !\nLa victoire est à portée de main !", + redNearVictory = "AVERTISSEMENT : Les forces %s ont capturé %d%% des zones !\nDéfendez nos positions !", + + -- Coalition names + coalitionBlue = "Coalition bleue", + coalitionRed = "Coalition rouge", + coalitionNeutral = "Neutre", + + -- Menu items + menuZoneControl = "Contrôle des zones", + menuStatusReport = "Obtenir le rapport d'état des zones", + menuVictoryProgress = "Vérifier la progression vers la victoire", + menuRefreshColors = "Actualiser les couleurs des zones", + menuLanguage = "Language / Sprache / Langue", + menuEnglish = "English", + menuGerman = "Deutsch (German)", + menuFrench = "Français (French)", + menuSpanish = "Español (Spanish)", + menuRussian = "Русский (Russian)", + + -- Victory progress messages + victoryProgressTitle = "PROGRÈS DE LA VICTOIRE : %d%%", + zonesCaptured = "Zones capturées : %d/%d", + zonesRemaining = "Restantes : %d zones", + progressComplete = "MISSION ACCOMPLIE !", + progressAlmostThere = "PRESQUE TERMINÉ !", + progressGood = "BON PROGRÈS !", + progressKeepFighting = "CONTINUEZ À COMBATTRE !", + + -- System messages + zoneMarkersRefreshed = "Les marqueurs de zone ont été actualisés !", + languageChanged = "Langue changée en français", + }, + + ES = { + -- Zone state messages + zoneGuarded = "%s está bajo la protección de %s", + zoneEmpty = "¡%s está desprotegida y puede ser capturada!", + zoneUnderAttack = "%s está siendo atacada por %s", + zoneWeAttacking = "Estamos atacando %s", + zoneCapturedByEnemy = "¡%s ha sido capturada por %s, la hemos perdido!", + zoneCapturedByUs = "¡Hemos capturado %s, excelente trabajo!", + + -- Victory messages + victoryBlue = "¡VICTORIA! ¡Todas las zonas de captura han sido aseguradas por las fuerzas %s!\n\n%s está completa. ¡Excelente trabajo!\nLa misión terminará en 60 segundos.", + defeatBlue = "¡DERROTA! Todas las posiciones estratégicas se han perdido ante las fuerzas %s.\n\n%s ha fallado. Misión terminando en 60 segundos.", + victoryRed = "¡VICTORIA! ¡Todas las posiciones estratégicas aseguradas para %s!\n\n¡Las fuerzas %s han sido repelidas. Excelente trabajo!\nLa misión terminará en 60 segundos.", + defeatRed = "¡DERROTA! Todas las zonas de captura se han perdido ante las fuerzas %s.\n\n%s ha fallado. Misión terminando en 60 segundos.", + missionComplete = "¡Misión completada! ¡Felicitaciones por tu victoria!\nEstado final: Todas las %d zonas estratégicas aseguradas.", + + -- Status report messages + zoneControlReport = "INFORME DE CONTROL DE ZONAS:\nCoalición Azul: %d/%d zonas\nCoalición Roja: %d/%d zonas\nNeutral: %d/%d zonas", + zoneDetails = "\nDETALLES DE ZONAS:\n", + zoneDetailLine = "• %s: %s\n", + yourProgress = "\n\nTu progreso hacia la victoria: %d%%", + + -- Progress warning messages + blueNearVictory = "ALERTA TÁCTICA: ¡Las fuerzas %s controlan el %d%% de las zonas estratégicas!\n¡La victoria está al alcance!", + redNearVictory = "ADVERTENCIA: ¡Las fuerzas %s han capturado el %d%% de las zonas!\n¡Defiende nuestras posiciones!", + + -- Coalition names + coalitionBlue = "Coalición Azul", + coalitionRed = "Coalición Roja", + coalitionNeutral = "Neutral", + + -- Menu items + menuZoneControl = "Control de zonas", + menuStatusReport = "Obtener informe de estado de zonas", + menuVictoryProgress = "Verificar progreso de victoria", + menuRefreshColors = "Actualizar colores de zonas", + menuLanguage = "Language / Sprache / Langue", + menuEnglish = "English", + menuGerman = "Deutsch (German)", + menuFrench = "Français (French)", + menuSpanish = "Español (Spanish)", + menuRussian = "Русский (Russian)", + + -- Victory progress messages + victoryProgressTitle = "PROGRESO DE VICTORIA: %d%%", + zonesCaptured = "Zonas capturadas: %d/%d", + zonesRemaining = "Restantes: %d zonas", + progressComplete = "¡MISIÓN COMPLETADA!", + progressAlmostThere = "¡CASI TERMINADO!", + progressGood = "¡BUEN PROGRESO!", + progressKeepFighting = "¡SIGUE LUCHANDO!", + + -- System messages + zoneMarkersRefreshed = "¡Los marcadores de zona se han actualizado!", + languageChanged = "Idioma cambiado a español", + }, + + RU = { + -- Zone state messages + zoneGuarded = "%s находится под защитой %s", + zoneEmpty = "%s не защищена и может быть захвачена!", + zoneUnderAttack = "%s атакована силами %s", + zoneWeAttacking = "Мы атакуем %s", + zoneCapturedByEnemy = "%s захвачена %s, мы её потеряли!", + zoneCapturedByUs = "Мы захватили %s, отличная работа!", + + -- Victory messages + victoryBlue = "ПОБЕДА! Все зоны захвата обеспечены силами %s!\n\n%s завершена. Отличная работа!\nМиссия закончится через 60 секунд.", + defeatBlue = "ПОРАЖЕНИЕ! Все стратегические позиции потеряны силами %s.\n\n%s провалена. Миссия заканчивается через 60 секунд.", + victoryRed = "ПОБЕДА! Все стратегические позиции обеспечены для %s!\n\nСилы %s отброшены. Отличная работа!\nМиссия закончится через 60 секунд.", + defeatRed = "ПОРАЖЕНИЕ! Все зоны захвата потеряны силами %s.\n\n%s провалена. Миссия заканчивается через 60 секунд.", + missionComplete = "Миссия выполнена! Поздравляем с победой!\nИтоговый статус: Все %d стратегических зон обеспечены.", + + -- Status report messages + zoneControlReport = "ОТЧЁТ О КОНТРОЛЕ ЗОН:\nСиняя коалиция: %d/%d зон\nКрасная коалиция: %d/%d зон\nНейтральные: %d/%d зон", + zoneDetails = "\nПОДРОБНОСТИ ЗОН:\n", + zoneDetailLine = "• %s: %s\n", + yourProgress = "\n\nВаш прогресс к победе: %d%%", + + -- Progress warning messages + blueNearVictory = "ТАКТИЧЕСКАЯ ТРЕВОГА: Силы %s контролируют %d%% стратегических зон!\nПобеда близка!", + redNearVictory = "ВНИМАНИЕ: Силы %s захватили %d%% зон!\nЗащищайте наши позиции!", + + -- Coalition names + coalitionBlue = "Синяя коалиция", + coalitionRed = "Красная коалиция", + coalitionNeutral = "Нейтральные", + + -- Menu items + menuZoneControl = "Контроль зон", + menuStatusReport = "Получить отчёт о статусе зон", + menuVictoryProgress = "Проверить прогресс победы", + menuRefreshColors = "Обновить цвета зон", + menuLanguage = "Language / Sprache / Langue", + menuEnglish = "English", + menuGerman = "Deutsch (German)", + menuFrench = "Français (French)", + menuSpanish = "Español (Spanish)", + menuRussian = "Русский (Russian)", + + -- Victory progress messages + victoryProgressTitle = "ПРОГРЕСС ПОБЕДЫ: %d%%", + zonesCaptured = "Зоны захвачены: %d/%d", + zonesRemaining = "Осталось: %d зон", + progressComplete = "МИССИЯ ВЫПОЛНЕНА!", + progressAlmostThere = "ПОЧТИ ГОТОВО!", + progressGood = "ХОРОШИЙ ПРОГРЕСС!", + progressKeepFighting = "ПРОДОЛЖАЙТЕ БОРОТЬСЯ!", + + -- System messages + zoneMarkersRefreshed = "Маркеры зон обновлены!", + languageChanged = "Язык изменён на русский", + } +} + +-- ========================================== +-- PLAYER LANGUAGE TRACKING +-- ========================================== +-- Store per-player language preferences +local playerLanguages = {} + +-- Helper function to get player's language preference +local function GetPlayerLanguage(playerName) + if playerName and playerLanguages[playerName] then + return playerLanguages[playerName] + end + return LANGUAGE_CONFIG.defaultLanguage +end + +-- Helper function to set player's language preference +local function SetPlayerLanguage(playerName, language) + if playerName and language then + playerLanguages[playerName] = language + env.info(string.format("[LANGUAGE] Player %s language set to %s", playerName, language)) + end +end + +-- Helper function to get translated text +local function GetText(textKey, playerName) + local lang = GetPlayerLanguage(playerName) + local langTable = ZONE_CAPTURE_LANGUAGES[lang] or ZONE_CAPTURE_LANGUAGES.EN + return langTable[textKey] or textKey +end + +-- Helper function to get coalition-specific text +local function GetCoalitionText(coalitionSide, playerName) + local lang = GetPlayerLanguage(playerName) + local langTable = ZONE_CAPTURE_LANGUAGES[lang] or ZONE_CAPTURE_LANGUAGES.EN + + if coalitionSide == coalition.side.BLUE then + return langTable.coalitionBlue + elseif coalitionSide == coalition.side.RED then + return langTable.coalitionRed + else + return langTable.coalitionNeutral + end +end + +-- Helper function to send message to coalition with language support +local function MessageToCoalition(commandCenter, messageKey, params, coalitionSide, duration) + if not commandCenter then return end + + -- Get all players in the coalition + local playerList = coalition.getPlayers(coalitionSide) + + if not playerList or #playerList == 0 then + -- No players in coalition, send default language message + local defaultText = ZONE_CAPTURE_LANGUAGES[LANGUAGE_CONFIG.defaultLanguage][messageKey] + if params then + defaultText = string.format(defaultText, unpack(params)) + end + commandCenter:MessageTypeToCoalition(defaultText, MESSAGE.Type.Information, duration) + return + end + + -- Group players by language preference + local languageGroups = {} + for _, unit in ipairs(playerList) do + if unit then + local playerName = unit:getPlayerName() + if playerName then + local lang = GetPlayerLanguage(playerName) + if not languageGroups[lang] then + languageGroups[lang] = {} + end + table.insert(languageGroups[lang], playerName) + end + end + end + + -- Send message in each language to respective players + for lang, players in pairs(languageGroups) do + local langTable = ZONE_CAPTURE_LANGUAGES[lang] or ZONE_CAPTURE_LANGUAGES.EN + local text = langTable[messageKey] + if params then + text = string.format(text, unpack(params)) + end + + -- For now, send to entire coalition (MOOSE doesn't support per-player filtering easily) + -- This is a limitation, but better than nothing + commandCenter:MessageTypeToCoalition(text, MESSAGE.Type.Information, duration) + end +end + + + -- ========================================== -- END OF CONFIGURATION -- ========================================== @@ -607,16 +994,16 @@ local function OnEnterGuarded(ZoneCapture, From, Event, To) ZoneCapture:UndrawZone() local color = ZONE_COLORS.BLUE_CAPTURED ZoneCapture:DrawZone(-1, {0, 0, 0}, 1, color, 0.2, 2, true) - US_CC:MessageTypeToCoalition( string.format( "%s is under protection of the %s", ZoneCapture:GetZoneName(), COALITION_TITLES.BLUE ), MESSAGE.Type.Information, MESSAGE_CONFIG.CAPTURE_MESSAGE_DURATION ) - RU_CC:MessageTypeToCoalition( string.format( "%s is under protection of the %s", ZoneCapture:GetZoneName(), COALITION_TITLES.BLUE ), MESSAGE.Type.Information, MESSAGE_CONFIG.CAPTURE_MESSAGE_DURATION ) + MessageToCoalition(US_CC, "zoneGuarded", {ZoneCapture:GetZoneName(), COALITION_TITLES.BLUE}, coalition.side.BLUE, MESSAGE_CONFIG.CAPTURE_MESSAGE_DURATION) + MessageToCoalition(RU_CC, "zoneGuarded", {ZoneCapture:GetZoneName(), COALITION_TITLES.BLUE}, coalition.side.RED, MESSAGE_CONFIG.CAPTURE_MESSAGE_DURATION) else ZoneCapture:Smoke( SMOKECOLOR.Red ) -- Update zone visual markers to RED ZoneCapture:UndrawZone() local color = ZONE_COLORS.RED_CAPTURED ZoneCapture:DrawZone(-1, {0, 0, 0}, 1, color, 0.2, 2, true) - RU_CC:MessageTypeToCoalition( string.format( "%s is under protection of the %s", ZoneCapture:GetZoneName(), COALITION_TITLES.RED ), MESSAGE.Type.Information, MESSAGE_CONFIG.CAPTURE_MESSAGE_DURATION ) - US_CC:MessageTypeToCoalition( string.format( "%s is under protection of the %s", ZoneCapture:GetZoneName(), COALITION_TITLES.RED ), MESSAGE.Type.Information, MESSAGE_CONFIG.CAPTURE_MESSAGE_DURATION ) + MessageToCoalition(RU_CC, "zoneGuarded", {ZoneCapture:GetZoneName(), COALITION_TITLES.RED}, coalition.side.RED, MESSAGE_CONFIG.CAPTURE_MESSAGE_DURATION) + MessageToCoalition(US_CC, "zoneGuarded", {ZoneCapture:GetZoneName(), COALITION_TITLES.RED}, coalition.side.BLUE, MESSAGE_CONFIG.CAPTURE_MESSAGE_DURATION) end -- Create/update tactical information marker CreateTacticalInfoMarker(ZoneCapture) @@ -629,8 +1016,8 @@ local function OnEnterEmpty(ZoneCapture) ZoneCapture:UndrawZone() local color = ZONE_COLORS.EMPTY ZoneCapture:DrawZone(-1, {0, 0, 0}, 1, color, 0.2, 2, true) - US_CC:MessageTypeToCoalition( string.format( "%s is unprotected, and can be captured!", ZoneCapture:GetZoneName() ), MESSAGE.Type.Information, MESSAGE_CONFIG.CAPTURE_MESSAGE_DURATION ) - RU_CC:MessageTypeToCoalition( string.format( "%s is unprotected, and can be captured!", ZoneCapture:GetZoneName() ), MESSAGE.Type.Information, MESSAGE_CONFIG.CAPTURE_MESSAGE_DURATION ) + MessageToCoalition(US_CC, "zoneEmpty", {ZoneCapture:GetZoneName()}, coalition.side.BLUE, MESSAGE_CONFIG.CAPTURE_MESSAGE_DURATION) + MessageToCoalition(RU_CC, "zoneEmpty", {ZoneCapture:GetZoneName()}, coalition.side.RED, MESSAGE_CONFIG.CAPTURE_MESSAGE_DURATION) -- Create/update tactical information marker CreateTacticalInfoMarker(ZoneCapture) end @@ -643,12 +1030,12 @@ local function OnEnterAttacked(ZoneCapture) local color if Coalition == coalition.side.BLUE then color = ZONE_COLORS.BLUE_ATTACKED - US_CC:MessageTypeToCoalition( string.format( "%s is under attack by %s", ZoneCapture:GetZoneName(), COALITION_TITLES.RED ), MESSAGE.Type.Information, MESSAGE_CONFIG.ATTACK_MESSAGE_DURATION ) - RU_CC:MessageTypeToCoalition( string.format( "We are attacking %s", ZoneCapture:GetZoneName() ), MESSAGE.Type.Information, MESSAGE_CONFIG.ATTACK_MESSAGE_DURATION ) + MessageToCoalition(US_CC, "zoneUnderAttack", {ZoneCapture:GetZoneName(), COALITION_TITLES.RED}, coalition.side.BLUE, MESSAGE_CONFIG.ATTACK_MESSAGE_DURATION) + MessageToCoalition(RU_CC, "zoneWeAttacking", {ZoneCapture:GetZoneName()}, coalition.side.RED, MESSAGE_CONFIG.ATTACK_MESSAGE_DURATION) else color = ZONE_COLORS.RED_ATTACKED - RU_CC:MessageTypeToCoalition( string.format( "%s is under attack by %s", ZoneCapture:GetZoneName(), COALITION_TITLES.BLUE ), MESSAGE.Type.Information, MESSAGE_CONFIG.ATTACK_MESSAGE_DURATION ) - US_CC:MessageTypeToCoalition( string.format( "We are attacking %s", ZoneCapture:GetZoneName() ), MESSAGE.Type.Information, MESSAGE_CONFIG.ATTACK_MESSAGE_DURATION ) + MessageToCoalition(RU_CC, "zoneUnderAttack", {ZoneCapture:GetZoneName(), COALITION_TITLES.BLUE}, coalition.side.RED, MESSAGE_CONFIG.ATTACK_MESSAGE_DURATION) + MessageToCoalition(US_CC, "zoneWeAttacking", {ZoneCapture:GetZoneName()}, coalition.side.BLUE, MESSAGE_CONFIG.ATTACK_MESSAGE_DURATION) end ZoneCapture:DrawZone(-1, {0, 0, 0}, 1, color, 0.2, 2, true) -- Create/update tactical information marker @@ -679,18 +1066,8 @@ local function CheckVictoryCondition() if blueZonesCount >= totalZones then log("[VICTORY] All zones captured by BLUE! Triggering victory sequence...") - US_CC:MessageTypeToCoalition( - "VICTORY! All capture zones have been secured by " .. COALITION_TITLES.BLUE .. " forces!\n\n" .. - COALITION_TITLES.BLUE_OPERATION .. " is complete. Outstanding work!\n" .. - "Mission will end in 60 seconds.", - MESSAGE.Type.Information, MESSAGE_CONFIG.VICTORY_MESSAGE_DURATION - ) - - RU_CC:MessageTypeToCoalition( - "DEFEAT! All strategic positions have been lost to " .. COALITION_TITLES.BLUE .. " forces.\n\n" .. - COALITION_TITLES.BLUE_OPERATION .. " has failed. Mission ending in 60 seconds.", - MESSAGE.Type.Information, MESSAGE_CONFIG.VICTORY_MESSAGE_DURATION - ) + MessageToCoalition(US_CC, "victoryBlue", {COALITION_TITLES.BLUE, COALITION_TITLES.BLUE_OPERATION}, coalition.side.BLUE, MESSAGE_CONFIG.VICTORY_MESSAGE_DURATION) + MessageToCoalition(RU_CC, "defeatBlue", {COALITION_TITLES.BLUE, COALITION_TITLES.BLUE_OPERATION}, coalition.side.RED, MESSAGE_CONFIG.VICTORY_MESSAGE_DURATION) -- Add victory celebration effects for _, zoneCapture in ipairs(zoneCaptureObjects) do @@ -707,10 +1084,7 @@ local function CheckVictoryCondition() log("[VICTORY] Ending mission due to complete zone capture by BLUE") trigger.action.setUserFlag("BLUE_VICTORY", 1) - US_CC:MessageTypeToCoalition( - string.format("Mission Complete! Congratulations on your victory!\nFinal Status: All %d strategic zones secured.", totalZones), - MESSAGE.Type.Information, 10 - ) + MessageToCoalition(US_CC, "missionComplete", {totalZones}, coalition.side.BLUE, 10) end, {}, 60 ) return true @@ -720,18 +1094,8 @@ local function CheckVictoryCondition() if redZonesCount >= totalZones then log("[VICTORY] All zones captured by RED! Triggering victory sequence...") - RU_CC:MessageTypeToCoalition( - "VICTORY! All strategic positions secured for " .. COALITION_TITLES.RED_OPERATION .. "!\n\n" .. - COALITION_TITLES.BLUE .. " forces have been repelled. Outstanding work!\n" .. - "Mission will end in 60 seconds.", - MESSAGE.Type.Information, MESSAGE_CONFIG.VICTORY_MESSAGE_DURATION - ) - - US_CC:MessageTypeToCoalition( - "DEFEAT! All capture zones have been lost to " .. COALITION_TITLES.RED .. " forces.\n\n" .. - COALITION_TITLES.BLUE_OPERATION .. " has failed. Mission ending in 60 seconds.", - MESSAGE.Type.Information, MESSAGE_CONFIG.VICTORY_MESSAGE_DURATION - ) + MessageToCoalition(RU_CC, "victoryRed", {COALITION_TITLES.RED_OPERATION, COALITION_TITLES.BLUE}, coalition.side.RED, MESSAGE_CONFIG.VICTORY_MESSAGE_DURATION) + MessageToCoalition(US_CC, "defeatRed", {COALITION_TITLES.RED, COALITION_TITLES.BLUE_OPERATION}, coalition.side.BLUE, MESSAGE_CONFIG.VICTORY_MESSAGE_DURATION) -- Add victory celebration effects for _, zoneCapture in ipairs(zoneCaptureObjects) do @@ -748,10 +1112,7 @@ local function CheckVictoryCondition() log("[VICTORY] Ending mission due to complete zone capture by RED") trigger.action.setUserFlag("RED_VICTORY", 1) - RU_CC:MessageTypeToCoalition( - string.format("Mission Complete! Congratulations on your victory!\nFinal Status: All %d strategic zones secured.", totalZones), - MESSAGE.Type.Information, 10 - ) + MessageToCoalition(RU_CC, "missionComplete", {totalZones}, coalition.side.RED, 10) end, {}, 60 ) return true @@ -767,15 +1128,15 @@ local function OnEnterCaptured(ZoneCapture) ZoneCapture:UndrawZone() local color = ZONE_COLORS.BLUE_CAPTURED ZoneCapture:DrawZone(-1, {0, 0, 0}, 1, color, 0.2, 2, true) - RU_CC:MessageTypeToCoalition( string.format( "%s is captured by the %s, we lost it!", ZoneCapture:GetZoneName(), COALITION_TITLES.BLUE ), MESSAGE.Type.Information, MESSAGE_CONFIG.CAPTURE_MESSAGE_DURATION ) - US_CC:MessageTypeToCoalition( string.format( "We captured %s, Excellent job!", ZoneCapture:GetZoneName() ), MESSAGE.Type.Information, MESSAGE_CONFIG.CAPTURE_MESSAGE_DURATION ) + MessageToCoalition(RU_CC, "zoneCapturedByEnemy", {ZoneCapture:GetZoneName(), COALITION_TITLES.BLUE}, coalition.side.RED, MESSAGE_CONFIG.CAPTURE_MESSAGE_DURATION) + MessageToCoalition(US_CC, "zoneCapturedByUs", {ZoneCapture:GetZoneName()}, coalition.side.BLUE, MESSAGE_CONFIG.CAPTURE_MESSAGE_DURATION) else -- Update zone visual markers to RED for captured ZoneCapture:UndrawZone() local color = ZONE_COLORS.RED_CAPTURED ZoneCapture:DrawZone(-1, {0, 0, 0}, 1, color, 0.2, 2, true) - US_CC:MessageTypeToCoalition( string.format( "%s is captured by the %s, we lost it!", ZoneCapture:GetZoneName(), COALITION_TITLES.RED ), MESSAGE.Type.Information, MESSAGE_CONFIG.CAPTURE_MESSAGE_DURATION ) - RU_CC:MessageTypeToCoalition( string.format( "We captured %s, Excellent job!", ZoneCapture:GetZoneName() ), MESSAGE.Type.Information, MESSAGE_CONFIG.CAPTURE_MESSAGE_DURATION ) + MessageToCoalition(US_CC, "zoneCapturedByEnemy", {ZoneCapture:GetZoneName(), COALITION_TITLES.RED}, coalition.side.BLUE, MESSAGE_CONFIG.CAPTURE_MESSAGE_DURATION) + MessageToCoalition(RU_CC, "zoneCapturedByUs", {ZoneCapture:GetZoneName()}, coalition.side.RED, MESSAGE_CONFIG.CAPTURE_MESSAGE_DURATION) end ZoneCapture:AddScore( "Captured", "Zone captured: Extra points granted.", ZONE_SETTINGS.captureScore ) @@ -908,36 +1269,60 @@ end local function BroadcastZoneStatus() local status = GetZoneOwnershipStatus() - -- Build coalition-neutral report - local reportMessage = string.format( - "ZONE CONTROL REPORT:\n" .. - "Blue Coalition: %d/%d zones\n" .. - "Red Coalition: %d/%d zones\n" .. - "Neutral: %d/%d zones", + -- Build report for BLUE coalition in their language + local bluePlayerList = coalition.getPlayers(coalition.side.BLUE) + local bluePlayerName = nil + if bluePlayerList and #bluePlayerList > 0 then + local unit = Unit.getByName(Unit.getName(bluePlayerList[1])) + if unit then + bluePlayerName = unit:getPlayerName() + end + end + + local blueReportMessage = string.format( + GetText("zoneControlReport", bluePlayerName), status.blue, status.total, status.red, status.total, status.neutral, status.total ) - -- Add detailed zone status - local detailMessage = "\nZONE DETAILS:\n" + local blueDetailMessage = GetText("zoneDetails", bluePlayerName) for zoneName, owner in pairs(status.zones) do - detailMessage = detailMessage .. string.format("• %s: %s\n", zoneName, owner) + blueDetailMessage = blueDetailMessage .. string.format(GetText("zoneDetailLine", bluePlayerName), zoneName, owner) end - local fullMessage = reportMessage .. detailMessage - - -- Broadcast to BOTH coalitions with their specific victory progress local totalZones = math.max(status.total, 1) local blueProgressPercent = math.floor((status.blue / totalZones) * 100) - local blueFullMessage = fullMessage .. string.format("\n\nYour Progress to Victory: %d%%", blueProgressPercent) + local blueFullMessage = blueReportMessage .. blueDetailMessage .. string.format(GetText("yourProgress", bluePlayerName), blueProgressPercent) US_CC:MessageTypeToCoalition( blueFullMessage, MESSAGE.Type.Information, MESSAGE_CONFIG.STATUS_MESSAGE_DURATION ) + -- Build report for RED coalition in their language + local redPlayerList = coalition.getPlayers(coalition.side.RED) + local redPlayerName = nil + if redPlayerList and #redPlayerList > 0 then + local unit = Unit.getByName(Unit.getName(redPlayerList[1])) + if unit then + redPlayerName = unit:getPlayerName() + end + end + + local redReportMessage = string.format( + GetText("zoneControlReport", redPlayerName), + status.blue, status.total, + status.red, status.total, + status.neutral, status.total + ) + + local redDetailMessage = GetText("zoneDetails", redPlayerName) + for zoneName, owner in pairs(status.zones) do + redDetailMessage = redDetailMessage .. string.format(GetText("zoneDetailLine", redPlayerName), zoneName, owner) + end + local redProgressPercent = math.floor((status.red / totalZones) * 100) - local redFullMessage = fullMessage .. string.format("\n\nYour Progress to Victory: %d%%", redProgressPercent) + local redFullMessage = redReportMessage .. redDetailMessage .. string.format(GetText("yourProgress", redPlayerName), redProgressPercent) RU_CC:MessageTypeToCoalition( redFullMessage, MESSAGE.Type.Information, MESSAGE_CONFIG.STATUS_MESSAGE_DURATION ) - log("[ZONE STATUS] " .. reportMessage:gsub("\n", " | ")) + log("[ZONE STATUS] Blue:" .. status.blue .. "/" .. status.total .. " Red:" .. status.red .. "/" .. status.total) return status end @@ -948,32 +1333,16 @@ local ZoneMonitorScheduler = SCHEDULER:New( nil, function() -- Check if BLUE is close to victory (80% or more zones captured) if status.blue >= math.floor(status.total * 0.8) and status.blue < status.total then - US_CC:MessageTypeToCoalition( - string.format("APPROACHING VICTORY! %d more zone(s) needed for complete success!", - status.total - status.blue), - MESSAGE.Type.Information, MESSAGE_CONFIG.VICTORY_MESSAGE_DURATION - ) - - RU_CC:MessageTypeToCoalition( - string.format("CRITICAL SITUATION! %s forces control %d/%d zones! We must recapture territory!", - COALITION_TITLES.BLUE, status.blue, status.total), - MESSAGE.Type.Information, MESSAGE_CONFIG.VICTORY_MESSAGE_DURATION - ) + local bluePercent = math.floor((status.blue / status.total) * 100) + MessageToCoalition(US_CC, "blueNearVictory", {COALITION_TITLES.BLUE, bluePercent}, coalition.side.BLUE, MESSAGE_CONFIG.VICTORY_MESSAGE_DURATION) + MessageToCoalition(RU_CC, "redNearVictory", {COALITION_TITLES.BLUE, bluePercent}, coalition.side.RED, MESSAGE_CONFIG.VICTORY_MESSAGE_DURATION) end -- Check if RED is close to victory (80% or more zones captured) if status.red >= math.floor(status.total * 0.8) and status.red < status.total then - RU_CC:MessageTypeToCoalition( - string.format("APPROACHING VICTORY! %d more zone(s) needed for complete success!", - status.total - status.red), - MESSAGE.Type.Information, MESSAGE_CONFIG.VICTORY_MESSAGE_DURATION - ) - - US_CC:MessageTypeToCoalition( - string.format("CRITICAL SITUATION! %s forces control %d/%d zones! We must recapture territory!", - COALITION_TITLES.RED, status.red, status.total), - MESSAGE.Type.Information, MESSAGE_CONFIG.VICTORY_MESSAGE_DURATION - ) + local redPercent = math.floor((status.red / status.total) * 100) + MessageToCoalition(RU_CC, "blueNearVictory", {COALITION_TITLES.RED, redPercent}, coalition.side.RED, MESSAGE_CONFIG.VICTORY_MESSAGE_DURATION) + MessageToCoalition(US_CC, "redNearVictory", {COALITION_TITLES.RED, redPercent}, coalition.side.BLUE, MESSAGE_CONFIG.VICTORY_MESSAGE_DURATION) end end, {}, MESSAGE_CONFIG.STATUS_BROADCAST_START_DELAY, MESSAGE_CONFIG.STATUS_BROADCAST_FREQUENCY ) @@ -1086,81 +1455,188 @@ local function RefreshAllZoneColors() end -- Notify BOTH coalitions - US_CC:MessageTypeToCoalition("Zone visual markers have been refreshed!", MESSAGE.Type.Information, MESSAGE_CONFIG.STATUS_MESSAGE_DURATION) - RU_CC:MessageTypeToCoalition("Zone visual markers have been refreshed!", MESSAGE.Type.Information, MESSAGE_CONFIG.STATUS_MESSAGE_DURATION) + MessageToCoalition(US_CC, "zoneMarkersRefreshed", nil, coalition.side.BLUE, MESSAGE_CONFIG.STATUS_MESSAGE_DURATION) + MessageToCoalition(RU_CC, "zoneMarkersRefreshed", nil, coalition.side.RED, MESSAGE_CONFIG.STATUS_MESSAGE_DURATION) end --- Manual zone status commands for players (F10 radio menu) - BOTH COALITIONS -local function SetupZoneStatusCommands() - -- Add F10 radio menu commands for BLUE coalition - if US_CC then - -- Use MenuManager to create zone control menu under Mission Options - local USMenu = MenuManager and MenuManager.CreateCoalitionMenu(coalition.side.BLUE, "Zone Control") - or MENU_COALITION:New( coalition.side.BLUE, "Zone Control" ) - MENU_COALITION_COMMAND:New( coalition.side.BLUE, "Get Zone Status Report", USMenu, BroadcastZoneStatus ) - - MENU_COALITION_COMMAND:New( coalition.side.BLUE, "Check Victory Progress", USMenu, function() - local status = GetZoneOwnershipStatus() - local totalZones = math.max(status.total, 1) - local progressPercent = math.floor((status.blue / totalZones) * 100) - - US_CC:MessageTypeToCoalition( - string.format( - "VICTORY PROGRESS: %d%%\n" .. - "Zones Captured: %d/%d\n" .. - "Remaining: %d zones\n\n" .. - "%s", - progressPercent, - status.blue, status.total, - status.total - status.blue, - progressPercent >= 100 and "MISSION COMPLETE!" or - progressPercent >= 80 and "ALMOST THERE!" or - progressPercent >= 50 and "GOOD PROGRESS!" or - "KEEP FIGHTING!" - ), - MESSAGE.Type.Information, MESSAGE_CONFIG.STATUS_MESSAGE_DURATION - ) - end ) - - -- Add command to refresh zone colors (troubleshooting tool) - MENU_COALITION_COMMAND:New( coalition.side.BLUE, "Refresh Zone Colors", USMenu, RefreshAllZoneColors ) +-- NOTE: All menus are now created per-player in CreatePlayerLanguageMenu() function below +-- No coalition-wide menus needed + +-- ========================================== +-- PER-PLAYER MENU SETUP (ALL ZONE CONTROL FUNCTIONS) +-- ========================================== +-- Track player menus to avoid duplicates per group +-- Key format: "playerName|groupName" to handle aircraft switches +local playerMenus = {} + +-- Function to create complete Zone Control menu for a specific player +local function CreatePlayerMenu(playerUnit) + env.info("[MENU DEBUG] CreatePlayerMenu called") + if not playerUnit then + env.info("[MENU DEBUG] No playerUnit") + return end - -- Add F10 radio menu commands for RED coalition - if RU_CC then - -- Use MenuManager to create zone control menu under Mission Options - local RUMenu = MenuManager and MenuManager.CreateCoalitionMenu(coalition.side.RED, "Zone Control") - or MENU_COALITION:New( coalition.side.RED, "Zone Control" ) - MENU_COALITION_COMMAND:New( coalition.side.RED, "Get Zone Status Report", RUMenu, BroadcastZoneStatus ) + local playerName = playerUnit:getPlayerName() + env.info(string.format("[MENU DEBUG] PlayerName: %s", tostring(playerName))) + if not playerName then return end + + -- Get DCS group and convert to MOOSE GROUP wrapper + local dcsGroup = playerUnit:getGroup() + env.info(string.format("[MENU DEBUG] DCS Group: %s", tostring(dcsGroup and dcsGroup:getName() or "nil"))) + if not dcsGroup then return end + + local groupName = dcsGroup:getName() + local group = GROUP:FindByName(groupName) + env.info(string.format("[MENU DEBUG] MOOSE Group found: %s", tostring(group ~= nil))) + if not group then return end + + local menuKey = playerName .. "|" .. groupName + + -- Don't create duplicate menus for same player in same group + if playerMenus[menuKey] then + env.info(string.format("[MENU DEBUG] Menu already exists for %s", menuKey)) + return + end + + local playerCoalition = playerUnit:getCoalition() + + -- Get appropriate command center + local commandCenter = playerCoalition == coalition.side.BLUE and US_CC or RU_CC + if not commandCenter then + env.info("[MENU DEBUG] No command center found") + return + end + + env.info(string.format("[MENU] Creating Zone Control menu for player %s (group: %s)", playerName, groupName)) + + -- Create Zone Control root menu (use GROUP object, not group name string) + -- Use MenuManager if available to nest under "Mission Options" + local zoneControlMenu = MenuManager and MenuManager.CreateGroupMenu(group, GetText("menuZoneControl", playerName)) + or MENU_GROUP:New(group, GetText("menuZoneControl", playerName)) + + -- Add Zone Status Report command + MENU_GROUP_COMMAND:New(group, GetText("menuStatusReport", playerName), zoneControlMenu, function() + BroadcastZoneStatus() + end) + + -- Add Victory Progress command + MENU_GROUP_COMMAND:New(group, GetText("menuVictoryProgress", playerName), zoneControlMenu, function() + local status = GetZoneOwnershipStatus() + local totalZones = math.max(status.total, 1) + local progressPercent = 0 - MENU_COALITION_COMMAND:New( coalition.side.RED, "Check Victory Progress", RUMenu, function() - local status = GetZoneOwnershipStatus() - local totalZones = math.max(status.total, 1) - local progressPercent = math.floor((status.red / totalZones) * 100) - - RU_CC:MessageTypeToCoalition( - string.format( - "VICTORY PROGRESS: %d%%\n" .. - "Zones Captured: %d/%d\n" .. - "Remaining: %d zones\n\n" .. - "%s", - progressPercent, - status.red, status.total, - status.total - status.red, - progressPercent >= 100 and "MISSION COMPLETE!" or - progressPercent >= 80 and "ALMOST THERE!" or - progressPercent >= 50 and "GOOD PROGRESS!" or - "KEEP FIGHTING!" - ), - MESSAGE.Type.Information, MESSAGE_CONFIG.STATUS_MESSAGE_DURATION - ) - end ) + -- Calculate progress based on player's coalition + if playerCoalition == coalition.side.BLUE then + progressPercent = math.floor((status.blue / totalZones) * 100) + else + progressPercent = math.floor((status.red / totalZones) * 100) + end - -- Add command to refresh zone colors (troubleshooting tool) - MENU_COALITION_COMMAND:New( coalition.side.RED, "Refresh Zone Colors", RUMenu, RefreshAllZoneColors ) + -- Determine progress message based on percentage + local progressMsg + if progressPercent >= 100 then + progressMsg = GetText("progressComplete", playerName) + elseif progressPercent >= 80 then + progressMsg = GetText("progressAlmostThere", playerName) + elseif progressPercent >= 50 then + progressMsg = GetText("progressGood", playerName) + else + progressMsg = GetText("progressKeepFighting", playerName) + end + + local zonesCaptured = playerCoalition == coalition.side.BLUE and status.blue or status.red + local zonesRemaining = status.total - zonesCaptured + + MESSAGE:New( + string.format( + "%s\n%s\n%s\n\n%s", + string.format(GetText("victoryProgressTitle", playerName), progressPercent), + string.format(GetText("zonesCaptured", playerName), zonesCaptured, status.total), + string.format(GetText("zonesRemaining", playerName), zonesRemaining), + progressMsg + ), + MESSAGE_CONFIG.STATUS_MESSAGE_DURATION + ):ToGroup(group) + end) + + -- Add Refresh Zone Colors command + MENU_GROUP_COMMAND:New(group, GetText("menuRefreshColors", playerName), zoneControlMenu, function() + RefreshAllZoneColors() + end) + + -- Create Language submenu + local langMenu = MENU_GROUP:New(group, GetText("menuLanguage", playerName), zoneControlMenu) + + -- Add language options + MENU_GROUP_COMMAND:New(group, GetText("menuEnglish", playerName), langMenu, function() + SetPlayerLanguage(playerName, "EN") + MESSAGE:New(GetText("languageChanged", playerName), 5):ToGroup(group) + end) + + MENU_GROUP_COMMAND:New(group, GetText("menuGerman", playerName), langMenu, function() + SetPlayerLanguage(playerName, "DE") + MESSAGE:New(GetText("languageChanged", playerName), 5):ToGroup(group) + end) + + MENU_GROUP_COMMAND:New(group, GetText("menuFrench", playerName), langMenu, function() + SetPlayerLanguage(playerName, "FR") + MESSAGE:New(GetText("languageChanged", playerName), 5):ToGroup(group) + end) + + MENU_GROUP_COMMAND:New(group, GetText("menuSpanish", playerName), langMenu, function() + SetPlayerLanguage(playerName, "ES") + MESSAGE:New(GetText("languageChanged", playerName), 5):ToGroup(group) + end) + + MENU_GROUP_COMMAND:New(group, GetText("menuRussian", playerName), langMenu, function() + SetPlayerLanguage(playerName, "RU") + MESSAGE:New(GetText("languageChanged", playerName), 5):ToGroup(group) + end) + + playerMenus[menuKey] = true + log(string.format("[MENU] Zone Control menu created for %s in group %s", playerName, groupName)) +end + +-- Event handler to create player menus when players spawn +local function OnPlayerBirth(event) + env.info(string.format("[MENU DEBUG] OnPlayerBirth called, event id: %s", tostring(event.id))) + if event.id == world.event.S_EVENT_BIRTH and event.initiator then + local unit = event.initiator + local playerName = unit and unit:getPlayerName() + env.info(string.format("[MENU DEBUG] Birth event for player: %s", tostring(playerName))) + if unit and playerName then + -- Small delay to ensure everything is initialized + timer.scheduleFunction(function() + CreatePlayerMenu(unit) + end, nil, timer.getTime() + 2) + end end end +-- Set up event handler +world.addEventHandler({ + onEvent = OnPlayerBirth +}) + +-- Create menus for any players already in the mission +timer.scheduleFunction(function() + env.info("[MENU] Checking for existing players...") + for _, coalitionSide in pairs({coalition.side.BLUE, coalition.side.RED}) do + local players = coalition.getPlayers(coalitionSide) + env.info(string.format("[MENU DEBUG] Found %d players in coalition %d", players and #players or 0, coalitionSide)) + if players then + for _, unit in ipairs(players) do + local playerName = unit and unit:getPlayerName() + env.info(string.format("[MENU DEBUG] Processing existing player: %s", tostring(playerName))) + if unit and playerName then + CreatePlayerMenu(unit) + end + end + end + end +end, nil, timer.getTime() + 5) + -- Initialize zone status monitoring SCHEDULER:New( nil, function() log("[VICTORY SYSTEM] Initializing zone monitoring system...") @@ -1168,7 +1644,7 @@ SCHEDULER:New( nil, function() -- Initialize performance optimization caches InitializeCachedUnitSet() - SetupZoneStatusCommands() + -- Note: All menus are created per-player via event handlers -- Initial status report SCHEDULER:New( nil, function() diff --git a/Moose_DualCoalitionZoneCapture.miz b/Moose_DualCoalitionZoneCapture.miz index a7be9bc..fce6a5c 100644 Binary files a/Moose_DualCoalitionZoneCapture.miz and b/Moose_DualCoalitionZoneCapture.miz differ diff --git a/Moose_DynamicGroundBattle.zip b/Moose_DualCoalitionZoneCapture.zip similarity index 98% rename from Moose_DynamicGroundBattle.zip rename to Moose_DualCoalitionZoneCapture.zip index 134ee45..6b9b102 100644 Binary files a/Moose_DynamicGroundBattle.zip and b/Moose_DualCoalitionZoneCapture.zip differ diff --git a/Moose_DynamicGroundBattle_Plugin.lua b/Moose_DynamicGroundBattle_Plugin.lua index ef837fd..c4594cd 100644 --- a/Moose_DynamicGroundBattle_Plugin.lua +++ b/Moose_DynamicGroundBattle_Plugin.lua @@ -192,6 +192,318 @@ local blueArmorTemplates = { -- DO NOT EDIT BELOW THIS LINE ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- +-- ========================================== +-- MULTILINGUAL SUPPORT (uses main script's language settings) +-- ========================================== +-- This plugin uses the playerLanguages table from Moose_DualCoalitionZoneCapture.lua +-- Players set their language preference in the main script's F10 menu + +local DGB_LANGUAGES = { + EN = { + -- Warehouse messages + warehouseStatus = "[Warehouse Status]\nRed warehouses alive: %d Reinforcements: %d%%\nBlue warehouses alive: %d Reinforcements: %d%%\n", + reinforcementCapacity = "%s reinforcement capacity: %d%%", + warehouseFriendly = "Warehouse: %s\nThis warehouse needs to be protected.\n", + warehouseEnemy = "Warehouse: %s\nThis is a primary target as it is directly supplying enemy units.\n", + + -- Menu items + menuGroundBattle = "Ground Battle", + menuWarehouseStatus = "Check Warehouse Status", + menuSystemStats = "Show System Statistics", + + -- System statistics + statsTitle = "DYNAMIC GROUND BATTLE - SYSTEM STATUS", + statsConfiguration = "【CONFIGURATION】", + statsDefendersPerZone = " Defenders per Zone: %d", + statsDefenderRotation = " Defender Rotation: %s", + statsInfantryMovement = " Infantry Movement: %s", + statsTaskReassignment = " Task Reassignment: Every %ds", + statsWarehouseMarkers = " Warehouse Markers: %s", + statsSpawnLimits = "【SPAWN LIMITS】", + statsRedInfantry = " Red Infantry: %d/%d", + statsRedArmor = " Red Armor: %d/%d", + statsBlueInfantry = " Blue Infantry: %d/%d", + statsBlueArmor = " Blue Armor: %d/%d", + statsRedCoalition = "【RED COALITION】", + statsBlueCoalition = "【BLUE COALITION】", + statsWarehouses = " Warehouses: %d/%d (%d%%)", + statsActiveUnits = " Active Units: %d (%d inf, %d armor)", + statsDefenders = " Defenders: %d | Mobile: %d", + statsControlledZones = " Controlled Zones: %d", + statsGarrisoned = " - Garrisoned: %d", + statsUnderGarrisoned = " - Under-Garrisoned: %d", + statsInfantrySpawn = " Infantry Spawn: %ds", + statsInfantrySpawnPaused = " Infantry Spawn: PAUSED (no warehouses)", + statsArmorSpawn = " Armor Spawn: %ds", + statsArmorSpawnPaused = " Armor Spawn: PAUSED (no warehouses)", + statsSystemInfo = "【SYSTEM INFO】", + statsTotalZones = " Total Zones: %d", + statsActiveGarrisons = " Active Garrisons: %d", + statsTotalActiveUnits = " Total Active Units: %d", + statsTrackedGroups = " Tracked Groups: %d", + statsLuaMemory = " Lua Memory: %.1f MB", + statsWarningMemory = " ⚠️ WARNING: High memory usage!", + statsWarningGroups = " ⚠️ WARNING: High group count!", + enabled = "ENABLED", + disabled = "DISABLED", + }, + + DE = { + -- Warehouse messages + warehouseStatus = "[Lagerstatus]\nRote Lager aktiv: %d Verstärkungen: %d%%\nBlaue Lager aktiv: %d Verstärkungen: %d%%\n", + reinforcementCapacity = "%s Verstärkungskapazität: %d%%", + warehouseFriendly = "Lager: %s\nDieses Lager muss geschützt werden.\n", + warehouseEnemy = "Lager: %s\nDies ist ein Hauptziel, da es feindliche Einheiten direkt versorgt.\n", + + -- Menu items + menuGroundBattle = "Bodenkampf", + menuWarehouseStatus = "Lagerstatus prüfen", + menuSystemStats = "Systemstatistiken anzeigen", + + -- System statistics + statsTitle = "DYNAMISCHER BODENKAMPF - SYSTEMSTATUS", + statsConfiguration = "【KONFIGURATION】", + statsDefendersPerZone = " Verteidiger pro Zone: %d", + statsDefenderRotation = " Verteidiger-Rotation: %s", + statsInfantryMovement = " Infanterie-Bewegung: %s", + statsTaskReassignment = " Aufgabenzuweisung: Alle %ds", + statsWarehouseMarkers = " Lagermarkierungen: %s", + statsSpawnLimits = "【SPAWN-LIMITS】", + statsRedInfantry = " Rote Infanterie: %d/%d", + statsRedArmor = " Rote Panzer: %d/%d", + statsBlueInfantry = " Blaue Infanterie: %d/%d", + statsBlueArmor = " Blaue Panzer: %d/%d", + statsRedCoalition = "【ROTE KOALITION】", + statsBlueCoalition = "【BLAUE KOALITION】", + statsWarehouses = " Lager: %d/%d (%d%%)", + statsActiveUnits = " Aktive Einheiten: %d (%d Inf, %d Panzer)", + statsDefenders = " Verteidiger: %d | Mobil: %d", + statsControlledZones = " Kontrollierte Zonen: %d", + statsGarrisoned = " - Besetzt: %d", + statsUnderGarrisoned = " - Unterbesetzt: %d", + statsInfantrySpawn = " Infanterie-Spawn: %ds", + statsInfantrySpawnPaused = " Infanterie-Spawn: PAUSIERT (keine Lager)", + statsArmorSpawn = " Panzer-Spawn: %ds", + statsArmorSpawnPaused = " Panzer-Spawn: PAUSIERT (keine Lager)", + statsSystemInfo = "【SYSTEMINFO】", + statsTotalZones = " Zonen gesamt: %d", + statsActiveGarrisons = " Aktive Garnisonen: %d", + statsTotalActiveUnits = " Aktive Einheiten gesamt: %d", + statsTrackedGroups = " Verfolgte Gruppen: %d", + statsLuaMemory = " Lua-Speicher: %.1f MB", + statsWarningMemory = " ⚠️ WARNUNG: Hoher Speicherverbrauch!", + statsWarningGroups = " ⚠️ WARNUNG: Hohe Gruppenanzahl!", + enabled = "AKTIVIERT", + disabled = "DEAKTIVIERT", + }, + + FR = { + -- Warehouse messages + warehouseStatus = "[Statut des entrepôts]\nEntrepôts rouges actifs : %d Renforts : %d%%\nEntrepôts bleus actifs : %d Renforts : %d%%\n", + reinforcementCapacity = "Capacité de renfort %s : %d%%", + warehouseFriendly = "Entrepôt : %s\nCet entrepôt doit être protégé.\n", + warehouseEnemy = "Entrepôt : %s\nC'est une cible prioritaire car il approvisionne directement les unités ennemies.\n", + + -- Menu items + menuGroundBattle = "Combat terrestre", + menuWarehouseStatus = "Vérifier le statut des entrepôts", + menuSystemStats = "Afficher les statistiques système", + + -- System statistics + statsTitle = "COMBAT TERRESTRE DYNAMIQUE - ÉTAT DU SYSTÈME", + statsConfiguration = "【CONFIGURATION】", + statsDefendersPerZone = " Défenseurs par zone : %d", + statsDefenderRotation = " Rotation des défenseurs : %s", + statsInfantryMovement = " Mouvement infanterie : %s", + statsTaskReassignment = " Réaffectation des tâches : Tous les %ds", + statsWarehouseMarkers = " Marqueurs d'entrepôt : %s", + statsSpawnLimits = "【LIMITES DE SPAWN】", + statsRedInfantry = " Infanterie rouge : %d/%d", + statsRedArmor = " Blindés rouges : %d/%d", + statsBlueInfantry = " Infanterie bleue : %d/%d", + statsBlueArmor = " Blindés bleus : %d/%d", + statsRedCoalition = "【COALITION ROUGE】", + statsBlueCoalition = "【COALITION BLEUE】", + statsWarehouses = " Entrepôts : %d/%d (%d%%)", + statsActiveUnits = " Unités actives : %d (%d inf, %d blindés)", + statsDefenders = " Défenseurs : %d | Mobiles : %d", + statsControlledZones = " Zones contrôlées : %d", + statsGarrisoned = " - En garnison : %d", + statsUnderGarrisoned = " - Sous-garnison : %d", + statsInfantrySpawn = " Spawn infanterie : %ds", + statsInfantrySpawnPaused = " Spawn infanterie : PAUSE (pas d'entrepôts)", + statsArmorSpawn = " Spawn blindés : %ds", + statsArmorSpawnPaused = " Spawn blindés : PAUSE (pas d'entrepôts)", + statsSystemInfo = "【INFO SYSTÈME】", + statsTotalZones = " Zones totales : %d", + statsActiveGarrisons = " Garnisons actives : %d", + statsTotalActiveUnits = " Unités actives totales : %d", + statsTrackedGroups = " Groupes suivis : %d", + statsLuaMemory = " Mémoire Lua : %.1f Mo", + statsWarningMemory = " ⚠️ ATTENTION : Utilisation mémoire élevée !", + statsWarningGroups = " ⚠️ ATTENTION : Nombre de groupes élevé !", + enabled = "ACTIVÉ", + disabled = "DÉSACTIVÉ", + }, + + ES = { + -- Warehouse messages + warehouseStatus = "[Estado de almacenes]\nAlmacenes rojos activos: %d Refuerzos: %d%%\nAlmacenes azules activos: %d Refuerzos: %d%%\n", + reinforcementCapacity = "Capacidad de refuerzo %s: %d%%", + warehouseFriendly = "Almacén: %s\nEste almacén necesita ser protegido.\n", + warehouseEnemy = "Almacén: %s\nEste es un objetivo prioritario ya que está suministrando directamente unidades enemigas.\n", + + -- Menu items + menuGroundBattle = "Batalla terrestre", + menuWarehouseStatus = "Verificar estado de almacenes", + menuSystemStats = "Mostrar estadísticas del sistema", + + -- System statistics + statsTitle = "BATALLA TERRESTRE DINÁMICA - ESTADO DEL SISTEMA", + statsConfiguration = "【CONFIGURACIÓN】", + statsDefendersPerZone = " Defensores por zona: %d", + statsDefenderRotation = " Rotación de defensores: %s", + statsInfantryMovement = " Movimiento de infantería: %s", + statsTaskReassignment = " Reasignación de tareas: Cada %ds", + statsWarehouseMarkers = " Marcadores de almacén: %s", + statsSpawnLimits = "【LÍMITES DE APARICIÓN】", + statsRedInfantry = " Infantería roja: %d/%d", + statsRedArmor = " Blindados rojos: %d/%d", + statsBlueInfantry = " Infantería azul: %d/%d", + statsBlueArmor = " Blindados azules: %d/%d", + statsRedCoalition = "【COALICIÓN ROJA】", + statsBlueCoalition = "【COALICIÓN AZUL】", + statsWarehouses = " Almacenes: %d/%d (%d%%)", + statsActiveUnits = " Unidades activas: %d (%d inf, %d blindados)", + statsDefenders = " Defensores: %d | Móviles: %d", + statsControlledZones = " Zonas controladas: %d", + statsGarrisoned = " - Guarnecidas: %d", + statsUnderGarrisoned = " - Subguarnecidas: %d", + statsInfantrySpawn = " Aparición infantería: %ds", + statsInfantrySpawnPaused = " Aparición infantería: PAUSADA (sin almacenes)", + statsArmorSpawn = " Aparición blindados: %ds", + statsArmorSpawnPaused = " Aparición blindados: PAUSADA (sin almacenes)", + statsSystemInfo = "【INFO DEL SISTEMA】", + statsTotalZones = " Zonas totales: %d", + statsActiveGarrisons = " Guarniciones activas: %d", + statsTotalActiveUnits = " Unidades activas totales: %d", + statsTrackedGroups = " Grupos rastreados: %d", + statsLuaMemory = " Memoria Lua: %.1f MB", + statsWarningMemory = " ⚠️ ADVERTENCIA: ¡Uso de memoria elevado!", + statsWarningGroups = " ⚠️ ADVERTENCIA: ¡Cantidad de grupos elevada!", + enabled = "HABILITADO", + disabled = "DESHABILITADO", + }, + + RU = { + -- Warehouse messages + warehouseStatus = "[Статус складов]\nКрасные склады активны: %d Подкрепления: %d%%\nСиние склады активны: %d Подкрепления: %d%%\n", + reinforcementCapacity = "Мощность подкреплений %s: %d%%", + warehouseFriendly = "Склад: %s\nЭтот склад нужно защищать.\n", + warehouseEnemy = "Склад: %s\nЭто приоритетная цель, так как она напрямую снабжает вражеские подразделения.\n", + + -- Menu items + menuGroundBattle = "Наземный бой", + menuWarehouseStatus = "Проверить статус складов", + menuSystemStats = "Показать статистику системы", + + -- System statistics + statsTitle = "ДИНАМИЧЕСКИЙ НАЗЕМНЫЙ БОЙ - СТАТУС СИСТЕМЫ", + statsConfiguration = "【КОНФИГУРАЦИЯ】", + statsDefendersPerZone = " Защитников на зону: %d", + statsDefenderRotation = " Ротация защитников: %s", + statsInfantryMovement = " Движение пехоты: %s", + statsTaskReassignment = " Переназначение задач: Каждые %ds", + statsWarehouseMarkers = " Маркеры складов: %s", + statsSpawnLimits = "【ЛИМИТЫ ПОЯВЛЕНИЯ】", + statsRedInfantry = " Красная пехота: %d/%d", + statsRedArmor = " Красная бронетехника: %d/%d", + statsBlueInfantry = " Синяя пехота: %d/%d", + statsBlueArmor = " Синяя бронетехника: %d/%d", + statsRedCoalition = "【КРАСНАЯ КОАЛИЦИЯ】", + statsBlueCoalition = "【СИНЯЯ КОАЛИЦИЯ】", + statsWarehouses = " Склады: %d/%d (%d%%)", + statsActiveUnits = " Активные подразделения: %d (%d пех, %d брон)", + statsDefenders = " Защитники: %d | Мобильные: %d", + statsControlledZones = " Контролируемые зоны: %d", + statsGarrisoned = " - С гарнизоном: %d", + statsUnderGarrisoned = " - Недоукомплектованные: %d", + statsInfantrySpawn = " Появление пехоты: %ds", + statsInfantrySpawnPaused = " Появление пехоты: ПАУЗА (нет складов)", + statsArmorSpawn = " Появление техники: %ds", + statsArmorSpawnPaused = " Появление техники: ПАУЗА (нет складов)", + statsSystemInfo = "【ИНФОРМАЦИЯ О СИСТЕМЕ】", + statsTotalZones = " Всего зон: %d", + statsActiveGarrisons = " Активные гарнизоны: %d", + statsTotalActiveUnits = " Всего активных единиц: %d", + statsTrackedGroups = " Отслеживаемые группы: %d", + statsLuaMemory = " Память Lua: %.1f МБ", + statsWarningMemory = " ⚠️ ВНИМАНИЕ: Высокое использование памяти!", + statsWarningGroups = " ⚠️ ВНИМАНИЕ: Высокое количество групп!", + enabled = "ВКЛЮЧЕНО", + disabled = "ВЫКЛЮЧЕНО", + } +} + +-- Helper function to get player's language from main script +-- Falls back to English if playerLanguages is not available or player not found +local function DGB_GetPlayerLanguage(playerName) + -- Try to use the main script's playerLanguages table + if playerLanguages and playerName and playerLanguages[playerName] then + return playerLanguages[playerName] + end + + -- Try to use the main script's GetPlayerLanguage function if available + if GetPlayerLanguage and type(GetPlayerLanguage) == "function" and playerName then + local lang = GetPlayerLanguage(playerName) + if lang then return lang end + end + + -- Fall back to main script's default language + if LANGUAGE_CONFIG and LANGUAGE_CONFIG.defaultLanguage then + return LANGUAGE_CONFIG.defaultLanguage + end + + -- Ultimate fallback to English + return "EN" +end + +-- Helper function to get coalition's language (uses first player's language) +local function DGB_GetCoalitionLanguage(coalitionSide) + local playerList = coalition.getPlayers(coalitionSide) + + if playerList and #playerList > 0 then + local unit = playerList[1] + if unit then + local playerName = unit:getPlayerName() + if playerName then + return DGB_GetPlayerLanguage(playerName) + end + end + end + + -- Fall back to default language + if LANGUAGE_CONFIG and LANGUAGE_CONFIG.defaultLanguage then + return LANGUAGE_CONFIG.defaultLanguage + end + + return "EN" +end + +-- Helper function to get translated text +local function DGB_GetText(textKey, playerName) + local lang = DGB_GetPlayerLanguage(playerName) + local langTable = DGB_LANGUAGES[lang] or DGB_LANGUAGES.EN + return langTable[textKey] or DGB_LANGUAGES.EN[textKey] or textKey +end + +-- Helper function to get translated text for a coalition +local function DGB_GetTextForCoalition(textKey, coalitionSide) + local lang = DGB_GetCoalitionLanguage(coalitionSide) + local langTable = DGB_LANGUAGES[lang] or DGB_LANGUAGES.EN + return langTable[textKey] or DGB_LANGUAGES.EN[textKey] or textKey +end + env.info("[DGB PLUGIN] Dynamic Ground Battle Plugin initializing...") -- Validate that DualCoalitionZoneCapture is loaded @@ -418,15 +730,15 @@ local function addMarkPoints(warehouses, coalition) if coalition == 2 then -- Blue viewing if warehouse:GetCoalition() == 2 then - details = "Warehouse: " .. warehouse:GetName() .. "\nThis warehouse needs to be protected.\n" + details = string.format(DGB_GetTextForCoalition("warehouseFriendly", coalition), warehouse:GetName()) else - details = "Warehouse: " .. warehouse:GetName() .. "\nThis is a primary target as it is directly supplying enemy units.\n" + details = string.format(DGB_GetTextForCoalition("warehouseEnemy", coalition), warehouse:GetName()) end elseif coalition == 1 then -- Red viewing if warehouse:GetCoalition() == 1 then - details = "Warehouse: " .. warehouse:GetName() .. "\nThis warehouse needs to be protected.\n" + details = string.format(DGB_GetTextForCoalition("warehouseFriendly", coalition), warehouse:GetName()) else - details = "Warehouse: " .. warehouse:GetName() .. "\nThis is a primary target as it is directly supplying enemy units.\n" + details = string.format(DGB_GetTextForCoalition("warehouseEnemy", coalition), warehouse:GetName()) end end @@ -818,10 +1130,23 @@ local function MonitorWarehouses() local blueSpawnFrequencyPercentage = CalculateSpawnFrequencyPercentage(blueWarehouses) if ENABLE_WAREHOUSE_STATUS_MESSAGES then - local msg = "[Warehouse Status]\n" - msg = msg .. "Red warehouses alive: " .. redWarehousesAlive .. " Reinforcements: " .. redSpawnFrequencyPercentage .. "%\n" - msg = msg .. "Blue warehouses alive: " .. blueWarehousesAlive .. " Reinforcements: " .. blueSpawnFrequencyPercentage .. "%\n" - MESSAGE:New(msg, 30):ToAll() + -- Send to Blue coalition in their language + local blueLang = DGB_GetCoalitionLanguage(coalition.side.BLUE) + local blueMsg = string.format( + DGB_LANGUAGES[blueLang].warehouseStatus, + redWarehousesAlive, redSpawnFrequencyPercentage, + blueWarehousesAlive, blueSpawnFrequencyPercentage + ) + MESSAGE:New(blueMsg, 30):ToBlue() + + -- Send to Red coalition in their language + local redLang = DGB_GetCoalitionLanguage(coalition.side.RED) + local redMsg = string.format( + DGB_LANGUAGES[redLang].warehouseStatus, + redWarehousesAlive, redSpawnFrequencyPercentage, + blueWarehousesAlive, blueSpawnFrequencyPercentage + ) + MESSAGE:New(redMsg, 30):ToRed() end env.info(string.format("[DGB PLUGIN] Warehouse status - Red: %d/%d (%d%%), Blue: %d/%d (%d%%)", @@ -920,73 +1245,77 @@ local function ShowSystemStatistics(playerCoalition) local blueInfantryInterval = CalculateSpawnFrequency(blueWarehouses, SPAWN_SCHED_BLUE_INFANTRY, BLUE_INFANTRY_CADENCE_SCALAR) local blueArmorInterval = CalculateSpawnFrequency(blueWarehouses, SPAWN_SCHED_BLUE_ARMOR, BLUE_ARMOR_CADENCE_SCALAR) + -- Get language for this coalition + local lang = DGB_GetCoalitionLanguage(playerCoalition) + local T = DGB_LANGUAGES[lang] + -- Build comprehensive report local msg = "═══════════════════════════════════════\n" - msg = msg .. "DYNAMIC GROUND BATTLE - SYSTEM STATUS\n" + msg = msg .. T.statsTitle .. "\n" msg = msg .. "═══════════════════════════════════════\n\n" -- Configuration Section - msg = msg .. "【CONFIGURATION】\n" - msg = msg .. " Defenders per Zone: " .. DEFENDERS_PER_ZONE .. "\n" - msg = msg .. " Defender Rotation: " .. (ALLOW_DEFENDER_ROTATION and "ENABLED" or "DISABLED") .. "\n" - msg = msg .. " Infantry Movement: " .. (MOVING_INFANTRY_PATROLS and "ENABLED" or "DISABLED") .. "\n" - msg = msg .. " Task Reassignment: Every " .. ASSIGN_TASKS_SCHED .. "s\n" - msg = msg .. " Warehouse Markers: " .. (ENABLE_WAREHOUSE_MARKERS and "ENABLED" or "DISABLED") .. "\n\n" + msg = msg .. T.statsConfiguration .. "\n" + msg = msg .. string.format(T.statsDefendersPerZone, DEFENDERS_PER_ZONE) .. "\n" + msg = msg .. string.format(T.statsDefenderRotation, ALLOW_DEFENDER_ROTATION and T.enabled or T.disabled) .. "\n" + msg = msg .. string.format(T.statsInfantryMovement, MOVING_INFANTRY_PATROLS and T.enabled or T.disabled) .. "\n" + msg = msg .. string.format(T.statsTaskReassignment, ASSIGN_TASKS_SCHED) .. "\n" + msg = msg .. string.format(T.statsWarehouseMarkers, ENABLE_WAREHOUSE_MARKERS and T.enabled or T.disabled) .. "\n\n" -- Spawn Limits Section - msg = msg .. "【SPAWN LIMITS】\n" - msg = msg .. " Red Infantry: " .. INIT_RED_INFANTRY .. "/" .. MAX_RED_INFANTRY .. "\n" - msg = msg .. " Red Armor: " .. INIT_RED_ARMOR .. "/" .. MAX_RED_ARMOR .. "\n" - msg = msg .. " Blue Infantry: " .. INIT_BLUE_INFANTRY .. "/" .. MAX_BLUE_INFANTRY .. "\n" - msg = msg .. " Blue Armor: " .. INIT_BLUE_ARMOR .. "/" .. MAX_BLUE_ARMOR .. "\n\n" + msg = msg .. T.statsSpawnLimits .. "\n" + msg = msg .. string.format(T.statsRedInfantry, INIT_RED_INFANTRY, MAX_RED_INFANTRY) .. "\n" + msg = msg .. string.format(T.statsRedArmor, INIT_RED_ARMOR, MAX_RED_ARMOR) .. "\n" + msg = msg .. string.format(T.statsBlueInfantry, INIT_BLUE_INFANTRY, MAX_BLUE_INFANTRY) .. "\n" + msg = msg .. string.format(T.statsBlueArmor, INIT_BLUE_ARMOR, MAX_BLUE_ARMOR) .. "\n\n" -- Red Coalition Section - msg = msg .. "【RED COALITION】\n" - msg = msg .. " Warehouses: " .. redWarehousesAlive .. "/" .. redWarehouseTotal .. " (" .. redSpawnFreqPct .. "%)\n" - msg = msg .. " Active Units: " .. redUnits.total .. " (" .. redUnits.infantry .. " inf, " .. redUnits.armor .. " armor)\n" - msg = msg .. " Defenders: " .. redUnits.defenders .. " | Mobile: " .. redUnits.mobile .. "\n" - msg = msg .. " Controlled Zones: " .. redGarrison.totalZones .. "\n" - msg = msg .. " - Garrisoned: " .. redGarrison.garrisoned .. "\n" - msg = msg .. " - Under-Garrisoned: " .. redGarrison.underGarrisoned .. "\n" + msg = msg .. T.statsRedCoalition .. "\n" + msg = msg .. string.format(T.statsWarehouses, redWarehousesAlive, redWarehouseTotal, redSpawnFreqPct) .. "\n" + msg = msg .. string.format(T.statsActiveUnits, redUnits.total, redUnits.infantry, redUnits.armor) .. "\n" + msg = msg .. string.format(T.statsDefenders, redUnits.defenders, redUnits.mobile) .. "\n" + msg = msg .. string.format(T.statsControlledZones, redGarrison.totalZones) .. "\n" + msg = msg .. string.format(T.statsGarrisoned, redGarrison.garrisoned) .. "\n" + msg = msg .. string.format(T.statsUnderGarrisoned, redGarrison.underGarrisoned) .. "\n" if redInfantryInterval then - msg = msg .. " Infantry Spawn: " .. math.floor(redInfantryInterval) .. "s\n" + msg = msg .. string.format(T.statsInfantrySpawn, math.floor(redInfantryInterval)) .. "\n" else - msg = msg .. " Infantry Spawn: PAUSED (no warehouses)\n" + msg = msg .. T.statsInfantrySpawnPaused .. "\n" end if redArmorInterval then - msg = msg .. " Armor Spawn: " .. math.floor(redArmorInterval) .. "s\n\n" + msg = msg .. string.format(T.statsArmorSpawn, math.floor(redArmorInterval)) .. "\n\n" else - msg = msg .. " Armor Spawn: PAUSED (no warehouses)\n\n" + msg = msg .. T.statsArmorSpawnPaused .. "\n\n" end -- Blue Coalition Section - msg = msg .. "【BLUE COALITION】\n" - msg = msg .. " Warehouses: " .. blueWarehousesAlive .. "/" .. blueWarehouseTotal .. " (" .. blueSpawnFreqPct .. "%)\n" - msg = msg .. " Active Units: " .. blueUnits.total .. " (" .. blueUnits.infantry .. " inf, " .. blueUnits.armor .. " armor)\n" - msg = msg .. " Defenders: " .. blueUnits.defenders .. " | Mobile: " .. blueUnits.mobile .. "\n" - msg = msg .. " Controlled Zones: " .. blueGarrison.totalZones .. "\n" - msg = msg .. " - Garrisoned: " .. blueGarrison.garrisoned .. "\n" - msg = msg .. " - Under-Garrisoned: " .. blueGarrison.underGarrisoned .. "\n" + msg = msg .. T.statsBlueCoalition .. "\n" + msg = msg .. string.format(T.statsWarehouses, blueWarehousesAlive, blueWarehouseTotal, blueSpawnFreqPct) .. "\n" + msg = msg .. string.format(T.statsActiveUnits, blueUnits.total, blueUnits.infantry, blueUnits.armor) .. "\n" + msg = msg .. string.format(T.statsDefenders, blueUnits.defenders, blueUnits.mobile) .. "\n" + msg = msg .. string.format(T.statsControlledZones, blueGarrison.totalZones) .. "\n" + msg = msg .. string.format(T.statsGarrisoned, blueGarrison.garrisoned) .. "\n" + msg = msg .. string.format(T.statsUnderGarrisoned, blueGarrison.underGarrisoned) .. "\n" if blueInfantryInterval then - msg = msg .. " Infantry Spawn: " .. math.floor(blueInfantryInterval) .. "s\n" + msg = msg .. string.format(T.statsInfantrySpawn, math.floor(blueInfantryInterval)) .. "\n" else - msg = msg .. " Infantry Spawn: PAUSED (no warehouses)\n" + msg = msg .. T.statsInfantrySpawnPaused .. "\n" end if blueArmorInterval then - msg = msg .. " Armor Spawn: " .. math.floor(blueArmorInterval) .. "s\n\n" + msg = msg .. string.format(T.statsArmorSpawn, math.floor(blueArmorInterval)) .. "\n\n" else - msg = msg .. " Armor Spawn: PAUSED (no warehouses)\n\n" + msg = msg .. T.statsArmorSpawnPaused .. "\n\n" end -- System Info - msg = msg .. "【SYSTEM INFO】\n" - msg = msg .. " Total Zones: " .. #zoneCaptureObjects .. "\n" - msg = msg .. " Active Garrisons: " .. (redGarrison.garrisoned + blueGarrison.garrisoned) .. "\n" - msg = msg .. " Total Active Units: " .. (redUnits.total + blueUnits.total) .. "\n" + msg = msg .. T.statsSystemInfo .. "\n" + msg = msg .. string.format(T.statsTotalZones, #zoneCaptureObjects) .. "\n" + msg = msg .. string.format(T.statsActiveGarrisons, redGarrison.garrisoned + blueGarrison.garrisoned) .. "\n" + msg = msg .. string.format(T.statsTotalActiveUnits, redUnits.total + blueUnits.total) .. "\n" -- Memory and Performance Tracking local totalSpawnedGroups = 0 @@ -995,17 +1324,17 @@ local function ShowSystemStatistics(playerCoalition) end local luaMemoryKB = collectgarbage("count") - msg = msg .. " Tracked Groups: " .. totalSpawnedGroups .. "\n" - msg = msg .. " Lua Memory: " .. string.format("%.1f MB", luaMemoryKB / 1024) .. "\n" + msg = msg .. string.format(T.statsTrackedGroups, totalSpawnedGroups) .. "\n" + msg = msg .. string.format(T.statsLuaMemory, luaMemoryKB / 1024) .. "\n" -- Warning if memory is high if luaMemoryKB > 512000 then -- More than 500MB - msg = msg .. " ⚠️ WARNING: High memory usage!\n" + msg = msg .. T.statsWarningMemory .. "\n" end -- Warning if too many groups if totalSpawnedGroups > 200 then - msg = msg .. " ⚠️ WARNING: High group count!\n" + msg = msg .. T.statsWarningGroups .. "\n" end msg = msg .. "\n" @@ -1028,8 +1357,13 @@ local blueZones = GetZonesByCoalition(coalition.side.BLUE) local redSpawnFrequencyPercentage = CalculateSpawnFrequencyPercentage(redWarehouses) local blueSpawnFrequencyPercentage = CalculateSpawnFrequencyPercentage(blueWarehouses) -MESSAGE:New("Red reinforcement capacity: " .. redSpawnFrequencyPercentage .. "%", 30):ToRed() -MESSAGE:New("Blue reinforcement capacity: " .. blueSpawnFrequencyPercentage .. "%", 30):ToBlue() +local redLang = DGB_GetCoalitionLanguage(coalition.side.RED) +local redMsg = string.format(DGB_LANGUAGES[redLang].reinforcementCapacity, "Red", redSpawnFrequencyPercentage) +MESSAGE:New(redMsg, 30):ToRed() + +local blueLang = DGB_GetCoalitionLanguage(coalition.side.BLUE) +local blueMsg = string.format(DGB_LANGUAGES[blueLang].reinforcementCapacity, "Blue", blueSpawnFrequencyPercentage) +MESSAGE:New(blueMsg, 30):ToBlue() -- Initialize spawners env.info("[DGB PLUGIN] Initializing spawn systems...") @@ -1277,25 +1611,28 @@ SCHEDULER:New(nil, AssignTasksToGroups, {}, 15, ASSIGN_TASKS_SCHED) -- Add F10 menu for manual checks (using MenuManager if available) if MenuManager then -- Create coalition-specific menus under Mission Options - local blueMenu = MenuManager.CreateCoalitionMenu(coalition.side.BLUE, "Ground Battle") - MENU_COALITION_COMMAND:New(coalition.side.BLUE, "Check Warehouse Status", blueMenu, MonitorWarehouses) - MENU_COALITION_COMMAND:New(coalition.side.BLUE, "Show System Statistics", blueMenu, function() + local blueMenuText = DGB_GetTextForCoalition("menuGroundBattle", coalition.side.BLUE) + local blueMenu = MenuManager.CreateCoalitionMenu(coalition.side.BLUE, blueMenuText) + MENU_COALITION_COMMAND:New(coalition.side.BLUE, DGB_GetTextForCoalition("menuWarehouseStatus", coalition.side.BLUE), blueMenu, MonitorWarehouses) + MENU_COALITION_COMMAND:New(coalition.side.BLUE, DGB_GetTextForCoalition("menuSystemStats", coalition.side.BLUE), blueMenu, function() ShowSystemStatistics(coalition.side.BLUE) end) - local redMenu = MenuManager.CreateCoalitionMenu(coalition.side.RED, "Ground Battle") - MENU_COALITION_COMMAND:New(coalition.side.RED, "Check Warehouse Status", redMenu, MonitorWarehouses) - MENU_COALITION_COMMAND:New(coalition.side.RED, "Show System Statistics", redMenu, function() + local redMenuText = DGB_GetTextForCoalition("menuGroundBattle", coalition.side.RED) + local redMenu = MenuManager.CreateCoalitionMenu(coalition.side.RED, redMenuText) + MENU_COALITION_COMMAND:New(coalition.side.RED, DGB_GetTextForCoalition("menuWarehouseStatus", coalition.side.RED), redMenu, MonitorWarehouses) + MENU_COALITION_COMMAND:New(coalition.side.RED, DGB_GetTextForCoalition("menuSystemStats", coalition.side.RED), redMenu, function() ShowSystemStatistics(coalition.side.RED) end) else -- Fallback to root-level mission menu - local missionMenu = MENU_MISSION:New("Ground Battle") - MENU_MISSION_COMMAND:New("Check Warehouse Status", missionMenu, MonitorWarehouses) - MENU_MISSION_COMMAND:New("Show Blue Statistics", missionMenu, function() + local missionMenuText = DGB_GetTextForCoalition("menuGroundBattle", coalition.side.BLUE) + local missionMenu = MENU_MISSION:New(missionMenuText) + MENU_MISSION_COMMAND:New(DGB_GetTextForCoalition("menuWarehouseStatus", coalition.side.BLUE), missionMenu, MonitorWarehouses) + MENU_MISSION_COMMAND:New(DGB_GetTextForCoalition("menuSystemStats", coalition.side.BLUE) .. " (Blue)", missionMenu, function() ShowSystemStatistics(coalition.side.BLUE) end) - MENU_MISSION_COMMAND:New("Show Red Statistics", missionMenu, function() + MENU_MISSION_COMMAND:New(DGB_GetTextForCoalition("menuSystemStats", coalition.side.RED) .. " (Red)", missionMenu, function() ShowSystemStatistics(coalition.side.RED) end) end diff --git a/Moose_MenuManager.lua b/Moose_MenuManager.lua new file mode 100644 index 0000000..b040568 --- /dev/null +++ b/Moose_MenuManager.lua @@ -0,0 +1,164 @@ +--[[ + Unified F10 Menu Manager + + Purpose: Provides a centralized menu system to organize all mission scripts + into a consistent F10 menu structure. + + Menu Organization: + F10 -> F1: Mission Options (all other scripts go here) + F10 -> F2: CTLD (reserved position) + F10 -> F3: AFAC Control (reserved position) + + Usage: + 1. Load this script FIRST before any other menu-creating scripts + 2. Other scripts should use MenuManager to register their menus + + Example: + -- In your script, instead of: + -- local MyMenu = MENU_COALITION:New(coalition.side.BLUE, "My Script") + + -- Use: + -- local MyMenu = MenuManager.CreateCoalitionMenu(coalition.side.BLUE, "My Script") + +]]-- + +MenuManager = {} +MenuManager.Version = "1.1" + +-- Configuration +MenuManager.Config = { + EnableMissionOptionsMenu = true, -- Set to false to disable the parent menu system + MissionOptionsMenuName = "Mission Options", -- Name of the parent menu + Debug = false -- Set to true for debug messages +} + +-- Storage for menu references +MenuManager.Menus = { + Blue = {}, + Red = {}, + Mission = {} +} + +-- Parent menu references (created on first use) +MenuManager.ParentMenus = { + BlueCoalition = nil, + RedCoalition = nil, + Mission = nil +} + +-- Initialize the parent menus +function MenuManager.Initialize() + if MenuManager.Config.EnableMissionOptionsMenu then + -- Create the parent "Mission Options" menu for each coalition + MenuManager.ParentMenus.BlueCoalition = MENU_COALITION:New( + coalition.side.BLUE, + MenuManager.Config.MissionOptionsMenuName + ) + + MenuManager.ParentMenus.RedCoalition = MENU_COALITION:New( + coalition.side.RED, + MenuManager.Config.MissionOptionsMenuName + ) + + -- Note: MENU_MISSION not created to avoid duplicate empty menu + -- Scripts that need mission-wide menus should use MENU_MISSION directly + + if MenuManager.Config.Debug then + env.info("MenuManager: Initialized parent coalition menus") + end + end +end + +-- Create a coalition menu under "Mission Options" +-- @param coalitionSide: coalition.side.BLUE or coalition.side.RED +-- @param menuName: Name of the menu +-- @param parentMenu: (Optional) If provided, creates as submenu of this parent instead of Mission Options +-- @return: MENU_COALITION object +function MenuManager.CreateCoalitionMenu(coalitionSide, menuName, parentMenu) + if MenuManager.Config.EnableMissionOptionsMenu and not parentMenu then + -- Create under Mission Options + local parent = (coalitionSide == coalition.side.BLUE) + and MenuManager.ParentMenus.BlueCoalition + or MenuManager.ParentMenus.RedCoalition + + local menu = MENU_COALITION:New(coalitionSide, menuName, parent) + + if MenuManager.Config.Debug then + local coalitionName = (coalitionSide == coalition.side.BLUE) and "BLUE" or "RED" + env.info(string.format("MenuManager: Created coalition menu '%s' for %s", menuName, coalitionName)) + end + + return menu + else + -- Create as root menu or under provided parent + local menu = MENU_COALITION:New(coalitionSide, menuName, parentMenu) + return menu + end +end + +-- Create a mission menu (not nested under Mission Options, as that causes duplicates) +-- @param menuName: Name of the menu +-- @param parentMenu: (Optional) Parent menu +-- @return: MENU_MISSION object +-- Note: Mission menus are visible to all players and cannot be nested under coalition menus +function MenuManager.CreateMissionMenu(menuName, parentMenu) + -- Always create as root menu or under provided parent + -- Mission menus can't be nested under coalition-specific "Mission Options" + local menu = MENU_MISSION:New(menuName, parentMenu) + + if MenuManager.Config.Debug then + env.info(string.format("MenuManager: Created mission menu '%s'", menuName)) + end + + return menu +end + +-- Create a group menu under "Mission Options" for the player's coalition +-- @param group: GROUP object +-- @param menuName: Name of the menu +-- @param parentMenu: (Optional) If provided, creates as submenu of this parent instead of Mission Options +-- @return: MENU_GROUP object +function MenuManager.CreateGroupMenu(group, menuName, parentMenu) + if MenuManager.Config.EnableMissionOptionsMenu and not parentMenu then + -- Get group's coalition and create under appropriate Mission Options + local groupCoalition = group:getCoalition() + local parent = (groupCoalition == coalition.side.BLUE) + and MenuManager.ParentMenus.BlueCoalition + or MenuManager.ParentMenus.RedCoalition + + local menu = MENU_GROUP:New(group, menuName, parent) + + if MenuManager.Config.Debug then + local coalitionName = (groupCoalition == coalition.side.BLUE) and "BLUE" or "RED" + env.info(string.format("MenuManager: Created group menu '%s' for %s (coalition %s)", + menuName, group:getName(), coalitionName)) + end + + return menu + else + -- Create as root menu or under provided parent + local menu = MENU_GROUP:New(group, menuName, parentMenu) + return menu + end +end + +-- Helper to disable the parent menu system at runtime +function MenuManager.DisableParentMenus() + MenuManager.Config.EnableMissionOptionsMenu = false + env.info("MenuManager: Parent menu system disabled") +end + +-- Helper to enable the parent menu system at runtime +function MenuManager.EnableParentMenus() + MenuManager.Config.EnableMissionOptionsMenu = true + if not MenuManager.ParentMenus.BlueCoalition then + MenuManager.Initialize() + end + env.info("MenuManager: Parent menu system enabled") +end + +-- Initialize on load +MenuManager.Initialize() + +-- Announcement +env.info(string.format("MenuManager v%s loaded - Mission Options menu system ready", MenuManager.Version)) diff --git a/Moose_WWII_PilotIntuition.lua b/Moose_WWII_PilotIntuition.lua new file mode 100644 index 0000000..07f3775 --- /dev/null +++ b/Moose_WWII_PilotIntuition.lua @@ -0,0 +1,3750 @@ +---@diagnostic disable: undefined-field, undefined-global +---@diagnostic disable: undefined-global, lowercase-global +-- WWII Pilot Intuition System using Moose Framework +-- +-- Description: +-- This script simulates a WWII-era pilot intuition system for DCS World missions. It enhances immersion by providing realistic reconnaissance capabilities, +-- alerting pilots to nearby air and ground threats through voice-like messages and optional markers, mimicking how pilots spotted targets in WWII without modern aids. +-- +-- Purpose: +-- To bridge the gap between modern DCS gameplay and historical WWII aviation, where situational awareness relied on visual scanning, formation flying, and intuition. +-- It helps players maintain awareness of their surroundings in dense or chaotic environments, promoting better tactics and immersion. +-- +-- Use Case: +-- Ideal for WWII-themed missions, dogfights, or ground attack scenarios. Players can fly without relying on radar or labels, using the system to spot bandits, +-- ground units, and receive formation feedback. It's particularly useful in multiplayer servers for coordinated flights or solo play for enhanced challenge. + +-- Features: +-- - **Dynamic Detection Ranges**: Detection ranges automatically adjust based on formation flying and environmental conditions. Flying in tight formations with wingmen significantly boosts spotting distances for both air and ground targets. For example, a solo pilot might detect air targets at 5km, but in a 2-ship formation, this increases to 10km, and in a 4-ship formation to 15km. Environmental factors like night reduce ranges (e.g., 50% at night), while bad weather can further decrease visibility. This simulates historical WWII reconnaissance where formation integrity was crucial for situational awareness. +-- - Line of Sight checks to ensure targets are only reported when visible. +-- - Realistic messaging simulating pilot radio calls for spotting bandits and ground threats. +-- - Formation integrity monitoring with alerts for wingmen presence. +-- - Dogfight assistance with alerts for merging bandits, tail warnings, head-on threats, and more. +-- - Optional visual markers (smoke, flares) for spotted targets, with selectable colors. +-- - Independent toggles for air target scanning, both globally (mission-wide) and per-player. +-- - On-demand ground target scanning with selective marking: scan nearby targets, review list, and choose which one to mark. +-- - Configurable settings via F10 menu for players to customize their experience. +-- - Scheduled summaries for players preferring less frequent updates. +-- - Per Player customization of dogfight assistance, marker types, and scanning preferences and message frequency. +-- - Per Player Multi Language Support: English, German, French, Spanish, Russian. + +-- +-- Setup Instructions: +-- 1. Ensure the Moose framework is installed and loaded in your DCS mission (download from https://flightcontrol-master.github.io/MOOSE_DOCS/). +-- 2. Add this script to your mission via the DCS Mission Editor: Go to Triggers > Mission Start > Do Script File, and select this Lua file. +-- 3. Optionally, customize the PILOT_INTUITION_CONFIG table below to adjust ranges, multipliers, and behaviors to fit your mission. +-- 4. The system initializes automatically on mission start. Players will see a welcome message and can access settings via the F10 menu. +-- 5. For best results, test in a mission with active AI units or other players to verify detection ranges and messaging. + +-- Logging system (0=NONE, 1=ERROR, 2=INFO, 3=DEBUG, 4=TRACE) +PILOT_INTUITION_LOG_LEVEL = 1 -- Default to INFO level + +local function PILog(level, message) + if level <= PILOT_INTUITION_LOG_LEVEL then + env.info(message) + end +end + +-- Log level constants +local LOG_NONE = 0 +local LOG_ERROR = 1 +local LOG_INFO = 2 +local LOG_DEBUG = 3 +local LOG_TRACE = 4 + +-- Global configuration table for settings +PILOT_INTUITION_CONFIG = { + airDetectionRange = 8000, -- Meters (base range for air targets) (can be modified by formation and environment) + groundDetectionRange = 5000, -- Meters (base range for ground targets) (can be modified by formation and environment) + messageCooldown = 10, -- Seconds between messages + markerType = "smoke_red", -- Options: "smoke_red", "smoke_green", "smoke_blue", "smoke_white", "flare_red", "flare_green", "flare_white", "none" + markerDuration = 300, -- Seconds for marker visibility (dcs default can't be changed, so this is just for reference) + threatHotRange = 1000, -- Meters for "hot" threat + threatColdRange = 5000, -- Meters for "cold" threat + scanInterval = 5, -- Seconds between scans + mergeRange = 500, -- Meters for merge detection + highMergeAltitude = 500, -- Meters altitude difference for "high merge" + lowMergeAltitude = 500, -- Meters altitude difference for "low merge" + tailWarningRange = 1000, -- Meters for tail warnings + headOnRange = 1000, -- Meters for head-on detection + beamRange = 1500, -- Meters for beam aspect detection + separatingRange = 2000, -- Meters - if opening past this after merge, call "separating" + multipleBanditsRange = 2000, -- Meters for multiple bandits detection + multipleBanditsWarningCooldown = 300, -- Seconds between "Multiple bandits in vicinity!" warnings (5 minutes) + maxThreatDisplay = 3, -- Maximum number of threats to display in multi-bandit tactical picture (most threatening first) + combatIntensityThreshold = 3, -- Number of bandits to trigger "high intensity" mode (increases cooldowns) + combatIntensityCooldownMultiplier = 1.5, -- Multiply dogfight assist cooldown by this during high intensity combat + suppressFormationInCombat = true, -- Suppress formation join/leave messages when engaged with bandits + formationRange = 1000, -- Meters for considering players in formation + minFormationWingmen = 1, -- Minimum wingmen for formation integrity warnings + formationMessageCooldown = 60, -- Seconds cooldown for formation join/leave messages (prevents spam) + nightDetectionMultiplier = 0.5, -- Detection range multiplier at night + badWeatherMultiplier = 0.7, -- Detection range multiplier in bad weather + dogfightAssistEnabled = true, -- Enable dogfight assistance by default + dogfightMessageCooldown = 3, -- Seconds between dogfight assist messages + criticalSpeedThreshold = 150, -- Knots - warn if speed drops below this in dogfight + altitudeDeltaThreshold = 300, -- Meters - significant altitude difference for callouts + highClosureRate = 100, -- M/s - warn if closure rate exceeds this + positionChangeThreshold = 45, -- Degrees - trigger update if bandit moves this much + maxMultiplier = 8, -- Max detection multiplier to prevent excessive ranges + summaryInterval = 120, -- Seconds between scheduled summaries (0 to disable) + summaryCooldown = 2, -- Minimum seconds between on-demand summaries + activeMessaging = true, -- Enable live alerts; false for on-demand only + showGlobalMenu = true, -- Enable global settings menu for players + showWelcomeMessage = true, -- Show welcome message to new players + enableCloseFlyingCompliments = true, -- Enable compliments for close flying + complimentRange = 75, -- Meters for close flying compliment + headOnWarningRange = 150, -- Meters for head-on warning + closeFlyingMessageCooldown = 26, -- Seconds between close flying messages + enableAirScanning = true, -- Enable scanning for air targets + enableGroundScanning = false, -- Enable scanning for ground targets + illuminationCooldown = 30, -- Seconds between illumination flare drops (simulates reload time) + illuminationAltitude = 500, -- Meters - altitude offset above target for illumination flares + illuminationFlaresDefault = 3, -- Number of illumination flares per sortie + countAIWingmen = true, -- Count AI units in same group as wingmen for formation bonus (false = players only count as wingmen) + aiWingmenMultiplier = 1.0, -- Multiplier for AI wingmen (0.5 = half credit, 1.0 = full credit) + distanceUnit = "mi", -- Default distance unit: "km" for kilometers, "mi" for miles (nautical miles) + defaultLanguage = "EN", -- Default language: "EN", "DE", "FR", "ES", "RU" +} + +-- Multilingual message and menu tables +-- Each language contains all messages and menu text +PILOT_INTUITION_LANGUAGES = { + EN = { + -- Messages + welcome = { + "Welcome to WWII Pilot Intuition! This system simulates pilot reconnaissance for spotting air and ground targets. Use F10 menu for settings.", + "Greetings, pilot! WWII Pilot Intuition is active. It helps you spot bandits and ground threats. Check F10 for options.", + "Pilot Intuition engaged! Simulate WWII-era reconnaissance. F10 menu for controls.", + }, + formationJoin = { + "You've joined flight with %s - air detection increased to %.0f%s, ground to %.0f%s.", + "%s is now flying off your wing - detection ranges boosted to %.0f%s air, %.0f%s ground.", + "Welcome aboard, %s! Formation tightens detection to %.0f%s for air, %.0f%s for ground.", + "%s joins the formation - eyes sharper now, %.0f%s air, %.0f%s ground range.", + }, + formationLeave = { + "%s left formation - air detection reduced to %.0f%s, ground to %.0f%s.", + "%s is outa here - detection drops to %.0f%s air, %.0f%s ground.", + "Formation broken by %s - ranges now %.0f%s air, %.0f%s ground.", + "%s has peeled off - back to solo detection: %.0f%s air, %.0f%s ground.", + }, + formationIntegrityLow = { + "Formation integrity low! Tighten up.", + "Form up, pilots! We're spread too thin.", + "Close ranks! Formation integrity compromised.", + "Get back in formation, lads! We're vulnerable.", + }, + airTargetDetected = { + "Bandit %s at %.0f degrees, %.1f %s, angels %.0f (%s)!", + "Enemy aircraft %s: %.0f degrees, %.1f %s, altitude %.0f (%s).", + "Bogey %s at %.0f o'clock, %.1f %s out, angels %.0f (%s).", + "Hostile contact %s: %.0f degrees, %.1f %s, %.0f angels (%s).", + "Bandit inbound %s: %.0f degrees, %.1f %s, angels %.0f (%s).", + }, + groundTargetDetected = { + "%s contact: %s %s at %.0f degrees, %.1f %s.", + "Ground threat: %s %s %s spotted at %.0f degrees, %.1f %s.", + "%s units detected: %s %s, %.0f degrees, %.1f %s.", + "Enemy ground: %s %s %s at bearing %.0f, %.1f %s away.", + }, + dogfightEngaged = { + "Engaged!", + "Tally ho! Dogfight started.", + "Bandit engaged! Fight's on.", + "Dogfight! Guns hot.", + }, + dogfightConcluded = { + "Dogfight concluded.", + "Fight's over. Clear.", + "Dogfight ended. Stand down.", + "Engagement terminated.", + }, + underFire = { + "Under fire! Break %s!%s", + "Taking hits! Evade %s!%s", + "Shots fired! Turn %s now!%s", + "Incoming! Break %s!%s", + }, + closeFlyingCompliment = { + "Nice flying! Tip-to-tip formation.", + "Hey, nice! That's some close flying.", + "Great job! That's close!", + "Easy does it there tiger, I don't know you that well!", + "Impressive! Wingtip to wingtip.", + "Smooth moves! Close quarters flying.", + "Damn, that's tight! Good flying.", + "Holy shit, that's close! Nice one.", + "Whew, that was close! Good stick work.", + "You're glued to my wing! Excellent flying.", + "Tight as a drum! Keep it up.", + "Helluva formation! Tip to tip.", + "Smooth as silk! Close flying there.", + "Damn, pilot! That's some precision.", + "Whoa, easy on the throttle! Nice and close.", + "Impressive control! Wingtip distance.", + "You're right on my six... wait, formation! Good job.", + "Tight formation! That's how it's done.", + "Holy cow, that's close! Well done.", + "Nice touch! Close quarters.", + "You're flying like a pro! Tight formation.", + "Smooth operator! Tip-to-tip.", + "Damn fine flying! Close as can be.", + "Whoa there! That's some tight flying.", + "Impressive! You're practically in my cockpit.", + "Smooth sailing! Close formation.", + "Hell yeah, that's tight! Good work.", + "Easy tiger, but damn good flying.", + "You're a formation expert! Tip-to-tip.", + "Smooth moves, pilot! Close quarters.", + }, + headOnWarning = { + "Whew! That was close!", + "Whoa! Nearly a head-on!", + "Close call! Watch that pass.", + "Damn, that was tight! Be careful.", + "Holy crap, almost collided!", + }, + markerSet = { + "Marker set to %s.", + "Markers now %s.", + "Marker type changed to %s.", + }, + dogfightAssistToggle = { + "Dogfight assist %s.", + "Dogfight assistance %s.", + }, + activeMessagingToggle = { + "Active messaging %s.", + "Live alerts %s.", + }, + airScanningToggle = { + "Air scanning %s.", + "Air detection %s.", + }, + groundScanningToggle = { + "Ground scanning %s.", + "Ground detection %s.", + }, + alertFrequencyToggle = { + "Alert frequency set to %s.", + "Alerts now %s.", + }, + summaryCooldown = { + "Summary on cooldown.", + "Wait a bit for another summary.", + }, + noThreats = { + "No active threats.", + "All clear.", + "Situation normal.", + }, + -- Additional hardcoded messages that need translation + systemActive = "WWII Pilot Intuition active! Use F10 menu for settings.", + logLevelSet = "Log level set to: %s", + distanceUnitsSet = "Distance units set to %s.", + targetNotAvailable = "Target %d not available.", + markedTarget = "Marked Target %d.", + noIlluminationFlares = "No illumination flares remaining. Land at a friendly airbase to rearm.", + illuminationNotReady = "Illumination not ready. Wait %d seconds.", + cannotDeterminePosition = "Cannot determine position for illumination drop.", + cannotDetermineAltitude = "Cannot determine altitude for illumination drop.", + illuminationDropped = "Illumination flare dropped at your position. (%d remaining)", + cannotDetermineTargetPosition = "Cannot determine target position for illumination drop.", + errorCouldNotDeterminePosition = "Error: Could not determine your position.", + illuminationDroppedOnTarget = "Illumination flare dropped on Target %d (%.0f°, %.1f%s). (%d remaining)", + illuminationRearmed = "Illumination flares rearmed: %d available.", + noGroundTargets = "No enemy ground targets detected in range.", + selectTargetFromMenu = "Select a target from the menu to mark it.", + targetInfo = "Target %d: %s %s %s, Bearing %.0f, Range %.1f %s", + -- State words + enabled = "enabled", + disabled = "disabled", + hot = "hot", + cold = "cold", + closing = "closing", + opening = "opening", + left = "left", + right = "right", + above = "above", + below = "below", + high = "high", + low = "low", + normal = "normal", + quiet = "quiet", + verbose = "verbose", + -- Menu text + menu = { + mainTitle = "WWII Pilot Intuition", + dogfightAssist = "Dogfight Assist", + enable = "Enable", + disable = "Disable", + markerType = "Marker Type", + smoke = "Smoke", + flare = "Flare", + red = "Red", + green = "Green", + blue = "Blue", + white = "White", + none = "None", + airScanning = "Air Scanning", + groundScanning = "Ground Scanning", + groundTargeting = "Ground Targeting", + scanForTargets = "Scan for Targets", + markTarget = "Mark Target", + target = "Target %d", + illumination = "Illumination (%d left)", + dropAtMyPosition = "Drop at My Position", + dropOnTarget = "Drop on Target", + alertFrequency = "Alert Frequency", + normalFreq = "Normal", + quietFreq = "Quiet", + verboseFreq = "Verbose", + summary = "Summary", + brief = "Brief", + detailed = "Detailed", + settingsAndGuides = "Settings & Player Guides", + distanceUnits = "Distance Units", + milesNautical = "Miles (Nautical)", + kilometers = "Kilometers", + playerGuide = "Player Guide", + systemOverview = "System Overview", + detectionRanges = "Detection Ranges", + dogfightAssistHelp = "Dogfight Assist Help", + groundTargetingHelp = "Ground Targeting Help", + formationTips = "Formation Tips", + illuminationHelp = "Illumination Help", + logLevel = "Log Level", + language = "Language / Sprache / Langue", + english = "English", + german = "Deutsch (German)", + french = "Français (French)", + spanish = "Español (Spanish)", + russian = "Русский (Russian)", + } + }, + -- German language + DE = { + welcome = { + "Willkommen bei WWII Piloten-Intuition! Dieses System simuliert Piloten-Aufklärung zum Erkennen von Luft- und Bodenzielen. Verwenden Sie das F10-Menü für Einstellungen.", + "Grüße, Pilot! WWII Piloten-Intuition ist aktiv. Es hilft Ihnen, Banditen und Bodenbedrohungen zu erkennen. Prüfen Sie F10 für Optionen.", + "Piloten-Intuition aktiviert! Simulieren Sie Aufklärung aus der WWII-Ära. F10-Menü für Steuerung.", + }, + formationJoin = { + "Sie haben sich dem Flug mit %s angeschlossen - Lufterkennung erhöht auf %.0f%s, Boden auf %.0f%s.", + "%s fliegt jetzt an Ihrem Flügel - Erkennungsreichweiten erhöht auf %.0f%s Luft, %.0f%s Boden.", + "Willkommen an Bord, %s! Formation verbessert Erkennung auf %.0f%s für Luft, %.0f%s für Boden.", + "%s schließt sich der Formation an - Augen sind schärfer jetzt, %.0f%s Luft, %.0f%s Boden Reichweite.", + }, + formationLeave = { + "%s hat Formation verlassen - Lufterkennung reduziert auf %.0f%s, Boden auf %.0f%s.", + "%s ist weg - Erkennung fällt auf %.0f%s Luft, %.0f%s Boden.", + "Formation durch %s aufgelöst - Reichweiten jetzt %.0f%s Luft, %.0f%s Boden.", + "%s hat sich abgesetzt - zurück zur Solo-Erkennung: %.0f%s Luft, %.0f%s Boden.", + }, + formationIntegrityLow = { + "Formationsintegrität niedrig! Zusammenrücken.", + "Formiert euch, Piloten! Wir sind zu weit auseinander.", + "Reihen schließen! Formationsintegrität gefährdet.", + "Zurück in Formation, Leute! Wir sind verwundbar.", + }, + airTargetDetected = { + "Bandit %s bei %.0f Grad, %.1f %s, Engel %.0f (%s)!", + "Feindflugzeug %s: %.0f Grad, %.1f %s, Höhe %.0f (%s).", + "Bogey %s bei %.0f Uhr, %.1f %s entfernt, Engel %.0f (%s).", + "Feindkontakt %s: %.0f Grad, %.1f %s, %.0f Engel (%s).", + "Bandit im Anflug %s: %.0f Grad, %.1f %s, Engel %.0f (%s).", + }, + groundTargetDetected = { + "%s Kontakt: %s %s bei %.0f Grad, %.1f %s.", + "Bodenbedrohung: %s %s %s gesichtet bei %.0f Grad, %.1f %s.", + "%s Einheiten entdeckt: %s %s, %.0f Grad, %.1f %s.", + "Feindlicher Boden: %s %s %s bei Peilung %.0f, %.1f %s entfernt.", + }, + dogfightEngaged = { + "Im Gefecht!", + "Tally ho! Luftkampf begonnen.", + "Bandit im Gefecht! Kampf läuft.", + "Luftkampf! Waffen scharf.", + }, + dogfightConcluded = { + "Luftkampf beendet.", + "Kampf vorbei. Klar.", + "Luftkampf beendet. Entspannen.", + "Gefecht beendet.", + }, + underFire = { + "Unter Beschuss! Ausweichen %s!%s", + "Treffer einsteckend! Ausweichen %s!%s", + "Schüsse abgefeuert! Wende %s jetzt!%s", + "Eingehend! Ausweichen %s!%s", + }, + closeFlyingCompliment = { + "Schönes Fliegen! Spitze-zu-Spitze-Formation.", + "Hey, schön! Das ist enges Fliegen.", + "Großartige Arbeit! Das ist nah!", + "Ruhig, Tiger, ich kenne dich nicht so gut!", + "Beeindruckend! Flügelspitze zu Flügelspitze.", + "Geschmeidige Bewegungen! Nahkampffliegen.", + "Verdammt, das ist eng! Gutes Fliegen.", + "Heilige Scheiße, das ist nah! Gut gemacht.", + "Puh, das war knapp! Gute Steuerung.", + "Du klebst an meinem Flügel! Exzellentes Fliegen.", + }, + headOnWarning = { + "Puh! Das war knapp!", + "Whoa! Fast frontal!", + "Knapper Anruf! Pass auf beim Überholen.", + "Verdammt, das war eng! Sei vorsichtig.", + "Heiliger Mist, fast kollidiert!", + }, + markerSet = { + "Markierung auf %s gesetzt.", + "Markierungen jetzt %s.", + "Markierungstyp geändert auf %s.", + }, + dogfightAssistToggle = { + "Luftkampf-Hilfe %s.", + "Luftkampf-Unterstützung %s.", + }, + activeMessagingToggle = { + "Aktive Benachrichtigungen %s.", + "Live-Warnungen %s.", + }, + airScanningToggle = { + "Luftscan %s.", + "Lufterkennung %s.", + }, + groundScanningToggle = { + "Bodenscan %s.", + "Bodenerkennung %s.", + }, + alertFrequencyToggle = { + "Warnfrequenz auf %s eingestellt.", + "Warnungen jetzt %s.", + }, + summaryCooldown = { + "Zusammenfassung in Abklingzeit.", + "Warten Sie etwas für eine weitere Zusammenfassung.", + }, + noThreats = { + "Keine aktiven Bedrohungen.", + "Alles klar.", + "Situation normal.", + }, + systemActive = "WWII Piloten-Intuition aktiv! Verwenden Sie das F10-Menü für Einstellungen.", + logLevelSet = "Log-Level auf %s gesetzt", + distanceUnitsSet = "Entfernungseinheiten auf %s eingestellt.", + targetNotAvailable = "Ziel %d nicht verfügbar.", + markedTarget = "Ziel %d markiert.", + noIlluminationFlares = "Keine Leuchtraketen mehr. Landen Sie auf einem befreundeten Flugplatz zum Auftanken.", + illuminationNotReady = "Beleuchtung nicht bereit. Warten Sie %d Sekunden.", + cannotDeterminePosition = "Kann Position für Leuchtraketen-Abwurf nicht bestimmen.", + cannotDetermineAltitude = "Kann Höhe für Leuchtraketen-Abwurf nicht bestimmen.", + illuminationDropped = "Leuchtrakete an Ihrer Position abgeworfen. (%d verbleibend)", + cannotDetermineTargetPosition = "Kann Zielposition für Leuchtraketen-Abwurf nicht bestimmen.", + errorCouldNotDeterminePosition = "Fehler: Konnte Ihre Position nicht bestimmen.", + illuminationDroppedOnTarget = "Leuchtrakete auf Ziel %d abgeworfen (%.0f°, %.1f%s). (%d verbleibend)", + illuminationRearmed = "Leuchtraketen aufgefüllt: %d verfügbar.", + noGroundTargets = "Keine feindlichen Bodenziele in Reichweite erkannt.", + selectTargetFromMenu = "Wählen Sie ein Ziel aus dem Menü zum Markieren.", + targetInfo = "Ziel %d: %s %s %s, Peilung %.0f, Reichweite %.1f %s", + enabled = "aktiviert", + disabled = "deaktiviert", + hot = "heiß", + cold = "kalt", + closing = "schließend", + opening = "öffnend", + left = "links", + right = "rechts", + above = "oben", + below = "unten", + high = "hoch", + low = "niedrig", + normal = "normal", + quiet = "ruhig", + verbose = "ausführlich", + menu = { + mainTitle = "WWII Piloten-Intuition", + dogfightAssist = "Luftkampf-Hilfe", + enable = "Aktivieren", + disable = "Deaktivieren", + markerType = "Markierungstyp", + smoke = "Rauch", + flare = "Leuchtrakete", + red = "Rot", + green = "Grün", + blue = "Blau", + white = "Weiß", + none = "Keine", + airScanning = "Luftscan", + groundScanning = "Bodenscan", + groundTargeting = "Bodenziel-Erfassung", + scanForTargets = "Nach Zielen scannen", + markTarget = "Ziel markieren", + target = "Ziel %d", + illumination = "Beleuchtung (%d übrig)", + dropAtMyPosition = "An meiner Position abwerfen", + dropOnTarget = "Auf Ziel abwerfen", + alertFrequency = "Warnfrequenz", + normalFreq = "Normal", + quietFreq = "Ruhig", + verboseFreq = "Ausführlich", + summary = "Zusammenfassung", + brief = "Kurz", + detailed = "Detailliert", + settingsAndGuides = "Einstellungen & Spielerführer", + distanceUnits = "Entfernungseinheiten", + milesNautical = "Meilen (Nautisch)", + kilometers = "Kilometer", + playerGuide = "Spielerführer", + systemOverview = "Systemübersicht", + detectionRanges = "Erkennungsreichweiten", + dogfightAssistHelp = "Luftkampf-Hilfe Anleitung", + groundTargetingHelp = "Bodenziel-Erfassung Anleitung", + formationTips = "Formationstipps", + illuminationHelp = "Beleuchtungs-Hilfe", + logLevel = "Log-Level", + language = "Language / Sprache / Langue", + english = "English (Englisch)", + german = "Deutsch (German)", + french = "Français (Französisch)", + spanish = "Español (Spanisch)", + russian = "Русский (Russisch)", + } + }, + -- French language + FR = { + welcome = { + "Bienvenue dans WWII Intuition du Pilote! Ce système simule la reconnaissance du pilote pour repérer les cibles aériennes et terrestres. Utilisez le menu F10 pour les paramètres.", + "Salutations, pilote! WWII Intuition du Pilote est actif. Il vous aide à repérer les bandits et les menaces au sol. Consultez F10 pour les options.", + "Intuition du pilote engagée! Simulez la reconnaissance de l'ère WWII. Menu F10 pour les commandes.", + }, + formationJoin = { + "Vous avez rejoint le vol avec %s - détection aérienne augmentée à %.0f%s, sol à %.0f%s.", + "%s vole maintenant sur votre aile - portées de détection augmentées à %.0f%s air, %.0f%s sol.", + "Bienvenue à bord, %s! La formation améliore la détection à %.0f%s pour l'air, %.0f%s pour le sol.", + "%s rejoint la formation - les yeux sont plus aiguisés maintenant, %.0f%s air, %.0f%s sol portée.", + }, + formationLeave = { + "%s a quitté la formation - détection aérienne réduite à %.0f%s, sol à %.0f%s.", + "%s est parti - détection chute à %.0f%s air, %.0f%s sol.", + "Formation rompue par %s - portées maintenant %.0f%s air, %.0f%s sol.", + "%s s'est détaché - retour à la détection solo: %.0f%s air, %.0f%s sol.", + }, + formationIntegrityLow = { + "Intégrité de formation faible! Resserrez-vous.", + "Formez-vous, pilotes! Nous sommes trop dispersés.", + "Serrez les rangs! Intégrité de formation compromise.", + "Revenez en formation, les gars! Nous sommes vulnérables.", + }, + airTargetDetected = { + "Bandit %s à %.0f degrés, %.1f %s, anges %.0f (%s)!", + "Avion ennemi %s: %.0f degrés, %.1f %s, altitude %.0f (%s).", + "Bogey %s à %.0f heures, %.1f %s, anges %.0f (%s).", + "Contact hostile %s: %.0f degrés, %.1f %s, %.0f anges (%s).", + "Bandit entrant %s: %.0f degrés, %.1f %s, anges %.0f (%s).", + }, + groundTargetDetected = { + "%s contact: %s %s à %.0f degrés, %.1f %s.", + "Menace au sol: %s %s %s repéré à %.0f degrés, %.1f %s.", + "%s unités détectées: %s %s, %.0f degrés, %.1f %s.", + "Sol ennemi: %s %s %s au relèvement %.0f, %.1f %s de distance.", + }, + dogfightEngaged = { + "Engagé!", + "Tally ho! Combat aérien commencé.", + "Bandit engagé! Le combat est lancé.", + "Combat aérien! Armes chaudes.", + }, + dogfightConcluded = { + "Combat aérien terminé.", + "Combat terminé. Dégagé.", + "Combat aérien terminé. Repos.", + "Engagement terminé.", + }, + underFire = { + "Sous le feu! Rompez %s!%s", + "Prend des coups! Évitez %s!%s", + "Coups tirés! Tournez %s maintenant!%s", + "Entrant! Rompez %s!%s", + }, + closeFlyingCompliment = { + "Beau vol! Formation bout à bout.", + "Hey, sympa! C'est du vol serré.", + "Excellent travail! C'est proche!", + "Doucement, tigre, je ne te connais pas si bien!", + "Impressionnant! Bout d'aile à bout d'aile.", + "Mouvements fluides! Vol en quartiers serrés.", + "Merde, c'est serré! Bon vol.", + "Putain, c'est proche! Bien joué.", + "Ouf, c'était proche! Bon maniement du manche.", + "Tu es collé à mon aile! Excellent vol.", + }, + headOnWarning = { + "Ouf! C'était proche!", + "Whoa! Presque en face!", + "Appel serré! Attention à ce passage.", + "Merde, c'était serré! Soyez prudent.", + "Sainte merde, presque entré en collision!", + }, + markerSet = { + "Marqueur défini sur %s.", + "Marqueurs maintenant %s.", + "Type de marqueur changé en %s.", + }, + dogfightAssistToggle = { + "Assistance combat aérien %s.", + "Assistance combat aérien %s.", + }, + activeMessagingToggle = { + "Messagerie active %s.", + "Alertes en direct %s.", + }, + airScanningToggle = { + "Scan aérien %s.", + "Détection aérienne %s.", + }, + groundScanningToggle = { + "Scan au sol %s.", + "Détection au sol %s.", + }, + alertFrequencyToggle = { + "Fréquence d'alerte définie sur %s.", + "Alertes maintenant %s.", + }, + summaryCooldown = { + "Résumé en temps de recharge.", + "Attendez un peu pour un autre résumé.", + }, + noThreats = { + "Aucune menace active.", + "Tout est clair.", + "Situation normale.", + }, + systemActive = "WWII Intuition du Pilote actif! Utilisez le menu F10 pour les paramètres.", + logLevelSet = "Niveau de log défini sur: %s", + distanceUnitsSet = "Unités de distance définies sur %s.", + targetNotAvailable = "Cible %d non disponible.", + markedTarget = "Cible %d marquée.", + noIlluminationFlares = "Plus de fusées éclairantes. Atterrissez sur une base aérienne amie pour vous réarmer.", + illuminationNotReady = "Illumination pas prête. Attendez %d secondes.", + cannotDeterminePosition = "Impossible de déterminer la position pour le largage de fusée éclairante.", + cannotDetermineAltitude = "Impossible de déterminer l'altitude pour le largage de fusée éclairante.", + illuminationDropped = "Fusée éclairante larguée à votre position. (%d restantes)", + cannotDetermineTargetPosition = "Impossible de déterminer la position de la cible pour le largage de fusée éclairante.", + errorCouldNotDeterminePosition = "Erreur: Impossible de déterminer votre position.", + illuminationDroppedOnTarget = "Fusée éclairante larguée sur cible %d (%.0f°, %.1f%s). (%d restantes)", + illuminationRearmed = "Fusées éclairantes réarmées: %d disponibles.", + noGroundTargets = "Aucune cible terrestre ennemie détectée à portée.", + selectTargetFromMenu = "Sélectionnez une cible dans le menu pour la marquer.", + targetInfo = "Cible %d: %s %s %s, Relèvement %.0f, Portée %.1f %s", + enabled = "activé", + disabled = "désactivé", + hot = "chaud", + cold = "froid", + closing = "rapprochement", + opening = "éloignement", + left = "gauche", + right = "droite", + above = "au-dessus", + below = "en-dessous", + high = "haut", + low = "bas", + normal = "normal", + quiet = "calme", + verbose = "verbeux", + menu = { + mainTitle = "Intuition du Pilote WWII", + dogfightAssist = "Assistance Combat Aérien", + enable = "Activer", + disable = "Désactiver", + markerType = "Type de Marqueur", + smoke = "Fumée", + flare = "Fusée", + red = "Rouge", + green = "Vert", + blue = "Bleu", + white = "Blanc", + none = "Aucun", + airScanning = "Scan Aérien", + groundScanning = "Scan au Sol", + groundTargeting = "Ciblage au Sol", + scanForTargets = "Scanner les Cibles", + markTarget = "Marquer Cible", + target = "Cible %d", + illumination = "Illumination (%d restantes)", + dropAtMyPosition = "Larguer à ma Position", + dropOnTarget = "Larguer sur Cible", + alertFrequency = "Fréquence d'Alerte", + normalFreq = "Normal", + quietFreq = "Calme", + verboseFreq = "Verbeux", + summary = "Résumé", + brief = "Bref", + detailed = "Détaillé", + settingsAndGuides = "Paramètres & Guides Joueurs", + distanceUnits = "Unités de Distance", + milesNautical = "Miles (Nautiques)", + kilometers = "Kilomètres", + playerGuide = "Guide du Joueur", + systemOverview = "Aperçu du Système", + detectionRanges = "Portées de Détection", + dogfightAssistHelp = "Aide Combat Aérien", + groundTargetingHelp = "Aide Ciblage au Sol", + formationTips = "Conseils Formation", + illuminationHelp = "Aide Illumination", + logLevel = "Niveau de Log", + language = "Language / Sprache / Langue", + english = "English (Anglais)", + german = "Deutsch (Allemand)", + french = "Français (French)", + spanish = "Español (Espagnol)", + russian = "Русский (Russe)", + } + }, + -- Spanish language + ES = { + welcome = { + "¡Bienvenido a WWII Intuición del Piloto! Este sistema simula el reconocimiento del piloto para detectar objetivos aéreos y terrestres. Use el menú F10 para configuración.", + "¡Saludos, piloto! WWII Intuición del Piloto está activo. Te ayuda a detectar bandidos y amenazas terrestres. Revisa F10 para opciones.", + "¡Intuición del piloto activada! Simula reconocimiento de la era WWII. Menú F10 para controles.", + }, + formationJoin = { + "Te has unido al vuelo con %s - detección aérea aumentada a %.0f%s, terrestre a %.0f%s.", + "%s ahora vuela en tu ala - rangos de detección aumentados a %.0f%s aéreo, %.0f%s terrestre.", + "¡Bienvenido a bordo, %s! La formación mejora detección a %.0f%s para aéreo, %.0f%s para terrestre.", + "%s se une a la formación - ojos más agudos ahora, %.0f%s aéreo, %.0f%s terrestre rango.", + }, + formationLeave = { + "%s dejó la formación - detección aérea reducida a %.0f%s, terrestre a %.0f%s.", + "%s se fue - detección cae a %.0f%s aéreo, %.0f%s terrestre.", + "Formación rota por %s - rangos ahora %.0f%s aéreo, %.0f%s terrestre.", + "%s se ha separado - vuelta a detección solitaria: %.0f%s aéreo, %.0f%s terrestre.", + }, + formationIntegrityLow = { + "¡Integridad de formación baja! Aprieten.", + "¡Formen, pilotos! Estamos demasiado dispersos.", + "¡Cierren filas! Integridad de formación comprometida.", + "¡Vuelvan a la formación, muchachos! Somos vulnerables.", + }, + airTargetDetected = { + "Bandido %s a %.0f grados, %.1f %s, ángeles %.0f (%s)!", + "Aeronave enemiga %s: %.0f grados, %.1f %s, altitud %.0f (%s).", + "Bogey %s a las %.0f en punto, %.1f %s fuera, ángeles %.0f (%s).", + "Contacto hostil %s: %.0f grados, %.1f %s, %.0f ángeles (%s).", + "Bandido entrante %s: %.0f grados, %.1f %s, ángeles %.0f (%s).", + }, + groundTargetDetected = { + "%s contacto: %s %s a %.0f grados, %.1f %s.", + "Amenaza terrestre: %s %s %s avistado a %.0f grados, %.1f %s.", + "%s unidades detectadas: %s %s, %.0f grados, %.1f %s.", + "Terreno enemigo: %s %s %s en rumbo %.0f, %.1f %s de distancia.", + }, + dogfightEngaged = { + "¡Enganchado!", + "¡Tally ho! Combate aéreo iniciado.", + "¡Bandido enganchado! La pelea está en marcha.", + "¡Combate aéreo! Armas calientes.", + }, + dogfightConcluded = { + "Combate aéreo concluido.", + "Pelea terminada. Despejado.", + "Combate aéreo terminado. Descanso.", + "Enganche terminado.", + }, + underFire = { + "¡Bajo fuego! ¡Rompe %s!%s", + "¡Recibiendo impactos! ¡Evade %s!%s", + "¡Disparos! ¡Gira %s ahora!%s", + "¡Entrante! ¡Rompe %s!%s", + }, + closeFlyingCompliment = { + "¡Buen vuelo! Formación punta con punta.", + "¡Hey, bien! Eso es vuelo cerrado.", + "¡Excelente trabajo! ¡Qué cerca!", + "¡Despacio ahí tigre, no te conozco tan bien!", + "¡Impresionante! Punta de ala con punta de ala.", + "¡Movimientos suaves! Vuelo en cuartos cerrados.", + "¡Diablos, qué ajustado! Buen vuelo.", + "¡Mierda, qué cerca! Bien hecho.", + "¡Uf, eso estuvo cerca! Buen control del stick.", + "¡Estás pegado a mi ala! Excelente vuelo.", + }, + headOnWarning = { + "¡Uf! ¡Eso estuvo cerca!", + "¡Whoa! ¡Casi de frente!", + "¡Llamada cerrada! Cuidado con ese pase.", + "¡Diablos, eso estuvo ajustado! Ten cuidado.", + "¡Santo cielo, casi colisionamos!", + }, + markerSet = { + "Marcador establecido en %s.", + "Marcadores ahora %s.", + "Tipo de marcador cambiado a %s.", + }, + dogfightAssistToggle = { + "Asistencia de combate aéreo %s.", + "Asistencia de combate aéreo %s.", + }, + activeMessagingToggle = { + "Mensajería activa %s.", + "Alertas en vivo %s.", + }, + airScanningToggle = { + "Escaneo aéreo %s.", + "Detección aérea %s.", + }, + groundScanningToggle = { + "Escaneo terrestre %s.", + "Detección terrestre %s.", + }, + alertFrequencyToggle = { + "Frecuencia de alerta establecida en %s.", + "Alertas ahora %s.", + }, + summaryCooldown = { + "Resumen en tiempo de reutilización.", + "Espera un poco para otro resumen.", + }, + noThreats = { + "Sin amenazas activas.", + "Todo despejado.", + "Situación normal.", + }, + systemActive = "¡WWII Intuición del Piloto activo! Use el menú F10 para configuración.", + logLevelSet = "Nivel de registro establecido en: %s", + distanceUnitsSet = "Unidades de distancia establecidas en %s.", + targetNotAvailable = "Objetivo %d no disponible.", + markedTarget = "Objetivo %d marcado.", + noIlluminationFlares = "No quedan bengalas de iluminación. Aterriza en una base aérea amiga para rearmarte.", + illuminationNotReady = "Iluminación no lista. Espera %d segundos.", + cannotDeterminePosition = "No se puede determinar la posición para lanzar bengala de iluminación.", + cannotDetermineAltitude = "No se puede determinar la altitud para lanzar bengala de iluminación.", + illuminationDropped = "Bengala de iluminación lanzada en tu posición. (%d restantes)", + cannotDetermineTargetPosition = "No se puede determinar la posición del objetivo para lanzar bengala de iluminación.", + errorCouldNotDeterminePosition = "Error: No se pudo determinar tu posición.", + illuminationDroppedOnTarget = "Bengala de iluminación lanzada en Objetivo %d (%.0f°, %.1f%s). (%d restantes)", + illuminationRearmed = "Bengalas de iluminación rearmadas: %d disponibles.", + noGroundTargets = "No se detectaron objetivos terrestres enemigos en rango.", + selectTargetFromMenu = "Selecciona un objetivo del menú para marcarlo.", + targetInfo = "Objetivo %d: %s %s %s, Rumbo %.0f, Rango %.1f %s", + enabled = "activado", + disabled = "desactivado", + hot = "caliente", + cold = "frío", + closing = "acercándose", + opening = "alejándose", + left = "izquierda", + right = "derecha", + above = "arriba", + below = "abajo", + high = "alto", + low = "bajo", + normal = "normal", + quiet = "silencioso", + verbose = "detallado", + menu = { + mainTitle = "Intuición del Piloto WWII", + dogfightAssist = "Asistencia Combate Aéreo", + enable = "Activar", + disable = "Desactivar", + markerType = "Tipo de Marcador", + smoke = "Humo", + flare = "Bengala", + red = "Rojo", + green = "Verde", + blue = "Azul", + white = "Blanco", + none = "Ninguno", + airScanning = "Escaneo Aéreo", + groundScanning = "Escaneo Terrestre", + groundTargeting = "Objetivo Terrestre", + scanForTargets = "Escanear Objetivos", + markTarget = "Marcar Objetivo", + target = "Objetivo %d", + illumination = "Iluminación (%d restantes)", + dropAtMyPosition = "Lanzar en Mi Posición", + dropOnTarget = "Lanzar en Objetivo", + alertFrequency = "Frecuencia de Alerta", + normalFreq = "Normal", + quietFreq = "Silencioso", + verboseFreq = "Detallado", + summary = "Resumen", + brief = "Breve", + detailed = "Detallado", + settingsAndGuides = "Configuración y Guías", + distanceUnits = "Unidades de Distancia", + milesNautical = "Millas (Náuticas)", + kilometers = "Kilómetros", + playerGuide = "Guía del Jugador", + systemOverview = "Resumen del Sistema", + detectionRanges = "Rangos de Detección", + dogfightAssistHelp = "Ayuda Combate Aéreo", + groundTargetingHelp = "Ayuda Objetivo Terrestre", + formationTips = "Consejos de Formación", + illuminationHelp = "Ayuda de Iluminación", + logLevel = "Nivel de Registro", + language = "Language / Sprache / Langue", + english = "English (Inglés)", + german = "Deutsch (Alemán)", + french = "Français (Francés)", + spanish = "Español (Spanish)", + russian = "Русский (Ruso)", + } + }, + -- Russian language + RU = { + welcome = { + "Добро пожаловать в WWII Интуиция Пилота! Эта система имитирует разведку пилота для обнаружения воздушных и наземных целей. Используйте меню F10 для настроек.", + "Приветствую, пилот! WWII Интуиция Пилота активна. Она поможет вам обнаружить бандитов и наземные угрозы. Проверьте F10 для опций.", + "Интуиция пилота активирована! Имитируйте разведку эпохи WWII. Меню F10 для управления.", + }, + formationJoin = { + "Вы присоединились к полету с %s - обнаружение воздушных целей увеличено до %.0f%s, наземных до %.0f%s.", + "%s теперь летит на вашем крыле - дальности обнаружения увеличены до %.0f%s воздух, %.0f%s земля.", + "Добро пожаловать на борт, %s! Формация улучшает обнаружение до %.0f%s для воздуха, %.0f%s для земли.", + "%s присоединяется к формации - глаза острее теперь, %.0f%s воздух, %.0f%s земля дальность.", + }, + formationLeave = { + "%s покинул формацию - обнаружение воздушных целей снижено до %.0f%s, наземных до %.0f%s.", + "%s ушел - обнаружение падает до %.0f%s воздух, %.0f%s земля.", + "Формация разорвана %s - дальности теперь %.0f%s воздух, %.0f%s земля.", + "%s отделился - обратно к одиночному обнаружению: %.0f%s воздух, %.0f%s земля.", + }, + formationIntegrityLow = { + "Целостность формации низкая! Сжаться.", + "Стройтесь, пилоты! Мы слишком разбросаны.", + "Сомкните ряды! Целостность формации под угрозой.", + "Вернитесь в формацию, ребята! Мы уязвимы.", + }, + airTargetDetected = { + "Бандит %s на %.0f градусов, %.1f %s, ангелы %.0f (%s)!", + "Вражеский самолет %s: %.0f градусов, %.1f %s, высота %.0f (%s).", + "Боги %s на %.0f часов, %.1f %s, ангелы %.0f (%s).", + "Враждебный контакт %s: %.0f градусов, %.1f %s, %.0f ангелов (%s).", + "Бандит входящий %s: %.0f градусов, %.1f %s, ангелы %.0f (%s).", + }, + groundTargetDetected = { + "%s контакт: %s %s на %.0f градусов, %.1f %s.", + "Наземная угроза: %s %s %s замечен на %.0f градусов, %.1f %s.", + "%s единицы обнаружены: %s %s, %.0f градусов, %.1f %s.", + "Вражеская земля: %s %s %s на пеленге %.0f, %.1f %s расстояние.", + }, + dogfightEngaged = { + "Вступил!", + "Талли хо! Воздушный бой начат.", + "Бандит вступил! Бой идет.", + "Воздушный бой! Оружие горячо.", + }, + dogfightConcluded = { + "Воздушный бой завершен.", + "Бой окончен. Чисто.", + "Воздушный бой закончен. Отбой.", + "Вступление завершено.", + }, + underFire = { + "Под огнем! Разрывай %s!%s", + "Получаем попадания! Уклоняйся %s!%s", + "Выстрелы! Поверни %s сейчас!%s", + "Входящий! Разрывай %s!%s", + }, + closeFlyingCompliment = { + "Отличный полет! Формация концом к концу.", + "Эй, хорошо! Это тесный полет.", + "Отличная работа! Как близко!", + "Полегче там, тигр, я тебя так хорошо не знаю!", + "Впечатляюще! Законцовка к законцовке.", + "Плавные движения! Полет в тесных четвертях.", + "Черт, как тесно! Хороший полет.", + "Святые небеса, как близко! Отлично.", + "Уф, это было близко! Хорошая работа ручкой.", + "Ты приклеен к моему крылу! Отличный полет.", + }, + headOnWarning = { + "Уф! Это было близко!", + "Воу! Почти лобовое!", + "Близкий вызов! Осторожнее с этим проходом.", + "Черт, это было тесно! Будь осторожен.", + "Святые небеса, почти столкнулись!", + }, + markerSet = { + "Маркер установлен на %s.", + "Маркеры теперь %s.", + "Тип маркера изменен на %s.", + }, + dogfightAssistToggle = { + "Помощь в воздушном бою %s.", + "Ассистенс воздушного боя %s.", + }, + activeMessagingToggle = { + "Активные сообщения %s.", + "Живые оповещения %s.", + }, + airScanningToggle = { + "Воздушное сканирование %s.", + "Воздушное обнаружение %s.", + }, + groundScanningToggle = { + "Наземное сканирование %s.", + "Наземное обнаружение %s.", + }, + alertFrequencyToggle = { + "Частота оповещений установлена на %s.", + "Оповещения теперь %s.", + }, + summaryCooldown = { + "Резюме на перезарядке.", + "Подожди немного для другого резюме.", + }, + noThreats = { + "Нет активных угроз.", + "Все чисто.", + "Ситуация нормальная.", + }, + systemActive = "WWII Интуиция Пилота активна! Используйте меню F10 для настроек.", + logLevelSet = "Уровень логирования установлен на: %s", + distanceUnitsSet = "Единицы расстояния установлены на %s.", + targetNotAvailable = "Цель %d недоступна.", + markedTarget = "Цель %d отмечена.", + noIlluminationFlares = "Осветительные ракеты закончились. Приземлитесь на дружественную авиабазу для перевооружения.", + illuminationNotReady = "Освещение не готово. Подождите %d секунд.", + cannotDeterminePosition = "Невозможно определить позицию для сброса осветительной ракеты.", + cannotDetermineAltitude = "Невозможно определить высоту для сброса осветительной ракеты.", + illuminationDropped = "Осветительная ракета сброшена на вашей позиции. (%d осталось)", + cannotDetermineTargetPosition = "Невозможно определить позицию цели для сброса осветительной ракеты.", + errorCouldNotDeterminePosition = "Ошибка: Не удалось определить вашу позицию.", + illuminationDroppedOnTarget = "Осветительная ракета сброшена на Цель %d (%.0f°, %.1f%s). (%d осталось)", + illuminationRearmed = "Осветительные ракеты перезаряжены: %d доступно.", + noGroundTargets = "Вражеские наземные цели в радиусе не обнаружены.", + selectTargetFromMenu = "Выберите цель из меню, чтобы отметить её.", + targetInfo = "Цель %d: %s %s %s, Пеленг %.0f, Дальность %.1f %s", + enabled = "включено", + disabled = "отключено", + hot = "горячий", + cold = "холодный", + closing = "сближение", + opening = "расхождение", + left = "слева", + right = "справа", + above = "сверху", + below = "снизу", + high = "высокий", + low = "низкий", + normal = "нормальный", + quiet = "тихий", + verbose = "подробный", + menu = { + mainTitle = "Интуиция Пилота WWII", + dogfightAssist = "Помощь Воздушный Бой", + enable = "Включить", + disable = "Отключить", + markerType = "Тип Маркера", + smoke = "Дым", + flare = "Ракета", + red = "Красный", + green = "Зеленый", + blue = "Синий", + white = "Белый", + none = "Нет", + airScanning = "Воздушное Сканирование", + groundScanning = "Наземное Сканирование", + groundTargeting = "Наземное Наведение", + scanForTargets = "Сканировать Цели", + markTarget = "Отметить Цель", + target = "Цель %d", + illumination = "Освещение (%d осталось)", + dropAtMyPosition = "Сбросить на Моей Позиции", + dropOnTarget = "Сбросить на Цель", + alertFrequency = "Частота Оповещений", + normalFreq = "Нормальная", + quietFreq = "Тихая", + verboseFreq = "Подробная", + summary = "Резюме", + brief = "Краткое", + detailed = "Подробное", + settingsAndGuides = "Настройки и Руководства", + distanceUnits = "Единицы Расстояния", + milesNautical = "Мили (Морские)", + kilometers = "Километры", + playerGuide = "Руководство Игрока", + systemOverview = "Обзор Системы", + detectionRanges = "Дальности Обнаружения", + dogfightAssistHelp = "Помощь Воздушный Бой", + groundTargetingHelp = "Помощь Наземное Наведение", + formationTips = "Советы по Формации", + illuminationHelp = "Помощь Освещение", + logLevel = "Уровень Логирования", + language = "Language / Sprache / Langue", + english = "English (Английский)", + german = "Deutsch (Немецкий)", + french = "Français (Французский)", + spanish = "Español (Испанский)", + russian = "Русский (Russian)", + } + } +} + +-- Backward compatibility: keep old reference for any direct usage +PILOT_INTUITION_MESSAGES = PILOT_INTUITION_LANGUAGES.EN + +-- Pilot Intuition Class +PilotIntuition = { + ClassName = "PilotIntuition", + players = {}, -- Table to track per-player data + trackedGroundTargets = {}, -- Global table to track ground targets + lastMessageTime = 0, + menu = nil, + enabled = true, + summaryScheduler = nil, + lastDeepCleanup = 0, -- For periodic deep cleanup + playerMenus = {}, -- Track created player menus to avoid duplicates +} + +-- Helper function to format distance based on player preference +function PilotIntuition:FormatDistance(distanceMeters, playerKey) + local distanceUnit = PILOT_INTUITION_CONFIG.distanceUnit + + -- Check for player-specific preference + if playerKey and self.players[playerKey] and self.players[playerKey].distanceUnit then + distanceUnit = self.players[playerKey].distanceUnit + end + + if distanceUnit == "mi" then + -- Convert to nautical miles (1 nautical mile = 1852 meters) + local distanceNM = distanceMeters / 1852 + return distanceNM, "mi" + else + -- Default to kilometers + local distanceKM = distanceMeters / 1000 + return distanceKM, "km" + end +end + +-- Helper function to get player's language +function PilotIntuition:GetPlayerLanguage(playerKey) + if playerKey and self.players[playerKey] and self.players[playerKey].language then + return self.players[playerKey].language + end + return PILOT_INTUITION_CONFIG.defaultLanguage +end + +-- Helper function to get translated text (non-array messages) +function PilotIntuition:GetText(textKey, playerKey) + local lang = self:GetPlayerLanguage(playerKey) + local langTable = PILOT_INTUITION_LANGUAGES[lang] or PILOT_INTUITION_LANGUAGES.EN + + -- Check if it's a nested key (e.g., "menu.mainTitle") + if string.find(textKey, "%.") then + local parts = {} + for part in string.gmatch(textKey, "[^%.]+") do + table.insert(parts, part) + end + + local value = langTable + for _, part in ipairs(parts) do + if value and type(value) == "table" then + value = value[part] + else + return textKey -- Fallback to key if not found + end + end + return value or textKey + end + + return langTable[textKey] or textKey +end + +-- Get random message with player language support +function PilotIntuition:GetRandomMessage(messageType, params, playerKey) + local lang = self:GetPlayerLanguage(playerKey) + local langTable = PILOT_INTUITION_LANGUAGES[lang] or PILOT_INTUITION_LANGUAGES.EN + local messages = langTable[messageType] + + if not messages then + env.info("PilotIntuition: No messages for type " .. tostring(messageType) .. " in language " .. lang) + return "Message type not found: " .. tostring(messageType) + end + + -- Handle both array messages and single string messages + local msg + if type(messages) == "table" and #messages > 0 then + msg = messages[math.random(#messages)] + elseif type(messages) == "string" then + msg = messages + else + return "Invalid message format for: " .. tostring(messageType) + end + + env.info("PilotIntuition: Selected message: " .. msg) + if params then + msg = string.format(msg, unpack(params)) + env.info("PilotIntuition: Formatted message: " .. msg) + end + return msg +end + +function PilotIntuition:New() + PILog(LOG_INFO, "PilotIntuition: System starting") + local self = BASE:Inherit(self, BASE:New()) + self.players = {} + self.trackedGroundTargets = {} + self.lastMessageTime = timer.getTime() + self.playerMenus = {} -- Clear cached menus on initialization + self:SetupMenu() + self:WireEventHandlers() + self:StartScheduler() + PILog(LOG_INFO, "PilotIntuition: System initialized") + return self +end + +-- Helper function to get current unit for a clientName +function PilotIntuition:GetUnitForClient(clientName) + if not clientName then + env.info("PilotIntuition: GetUnitForClient - no clientName provided") + return nil + end + + env.info("PilotIntuition: GetUnitForClient looking for: " .. tostring(clientName)) + + -- Try _DATABASE.CLIENTS first + if _DATABASE and _DATABASE.CLIENTS and _DATABASE.CLIENTS[clientName] then + local unitName = _DATABASE.CLIENTS[clientName].UnitName + env.info("PilotIntuition: Found in _DATABASE.CLIENTS, unitName: " .. tostring(unitName)) + if unitName then + local unit = UNIT:FindByName(unitName) + if unit then + env.info("PilotIntuition: UNIT:FindByName succeeded, IsAlive: " .. tostring(unit:IsAlive())) + if unit:IsAlive() then + return unit + end + else + env.info("PilotIntuition: UNIT:FindByName returned nil") + end + end + else + env.info("PilotIntuition: clientName not found in _DATABASE.CLIENTS") + end + + return nil +end + +-- Helper function to get player data key from a unit (handles aliasing) +function PilotIntuition:GetPlayerDataKey(playerUnit) + if not playerUnit then return nil end + + local unitName = playerUnit:GetName() + local playerName = playerUnit:GetPlayerName() + + -- Try unit name first (clientName from _DATABASE.CLIENTS) + if self.players[unitName] then + return unitName + end + + -- Try player name second (may be aliased to unit name) + if playerName and self.players[playerName] then + return playerName + end + + -- Try to find player data by searching _DATABASE.CLIENTS for matching unit + if _DATABASE and _DATABASE.CLIENTS then + for clientName, clientData in pairs(_DATABASE.CLIENTS) do + if clientData and clientData.UnitName == unitName then + if self.players[clientName] then + return clientName + end + end + end + end + + return nil +end + +-- Helper function to get all active player units +function PilotIntuition:GetActivePlayers() + local players = {} + local count = 0 + + -- Method 1: Try DCS coalition.getPlayers() + for _, coalitionId in pairs({coalition.side.RED, coalition.side.BLUE, coalition.side.NEUTRAL}) do + local success, coalPlayers = pcall(coalition.getPlayers, coalitionId) + if success and coalPlayers then + for _, playerId in pairs(coalPlayers) do + if playerId and type(playerId) == "string" and playerId ~= "" then + local success2, unit = pcall(Unit.getByName, playerId) + if success2 and unit and unit:isExist() and unit:isActive() then + local unitWrapper = UNIT:Find(unit) + if unitWrapper then + local playerName = unitWrapper:GetPlayerName() or unitWrapper:GetName() + players[playerName] = unitWrapper + count = count + 1 + env.info("PilotIntuition: Found player via coalition.getPlayers: " .. playerName) + end + end + end + end + end + end + + -- Method 2: Try _DATABASE.CLIENTS + if _DATABASE and _DATABASE.CLIENTS then + local clientCount = 0 + for clientName, clientData in pairs(_DATABASE.CLIENTS) do + clientCount = clientCount + 1 + PILog(LOG_TRACE, "PilotIntuition: Checking _DATABASE.CLIENTS entry #" .. clientCount .. ": " .. tostring(clientName)) + if clientData then + PILog(LOG_TRACE, " - UnitName: " .. tostring(clientData.UnitName)) + end + if not players[clientName] and clientData and clientData.UnitName then + local unit = UNIT:FindByName(clientData.UnitName) + PILog(LOG_TRACE, " - UNIT:FindByName result: " .. tostring(unit)) + if unit then + PILog(LOG_TRACE, " - Unit:IsAlive: " .. tostring(unit:IsAlive())) + end + if unit and unit:IsAlive() then + players[clientName] = unit + count = count + 1 + PILog(LOG_DEBUG, "PilotIntuition: Found player via _DATABASE.CLIENTS: " .. clientName) + end + end + end + PILog(LOG_DEBUG, "PilotIntuition: _DATABASE.CLIENTS had " .. clientCount .. " total entries") + else + PILog(LOG_ERROR, "PilotIntuition: _DATABASE.CLIENTS not available!") + end + + PILog(LOG_INFO, "PilotIntuition: GetActivePlayers found " .. count .. " players") + return players +end + +function PilotIntuition:ScanTargets() + PILog(LOG_DEBUG, "PilotIntuition: ScanTargets called") + if not self.enabled then + return + end + local startTime = timer.getTime() -- Profiling start + + local activePlayers = self:GetActivePlayers() + local activeClients = {} + local activePlayerNames = {} + + -- Build active client list from the players we found + for clientName, unit in pairs(activePlayers) do + if unit and unit:IsAlive() then + local client = unit:GetClient() + local pos = unit:GetCoordinate() + local actualPlayerName = unit:GetPlayerName() or unit:GetName() + activeClients[#activeClients + 1] = { client = client, unit = unit, name = clientName, pos = pos, coalition = unit:GetCoalition() } + activePlayerNames[clientName] = true + + -- Create player data if it doesn't exist (using clientName as primary key) + if not self.players[clientName] then + self.players[clientName] = { + trackedAirTargets = {}, + lastMessageTime = timer.getTime(), + lastFormationMessageTime = 3, -- Separate cooldown for formation join/leave messages + lastDogfightTime = 0, -- For multi-bandit tactical picture cooldown + wingmenList = {}, + formationWarned = false, + lastFormationChangeTime = 0, + lastConclusionTime = 0, + trackedGroundTargets = {}, + dogfightAssist = PILOT_INTUITION_CONFIG.dogfightAssistEnabled, + lastDogfightAssistTime = 0, + markerType = PILOT_INTUITION_CONFIG.markerType, + lastSpeed = 0, + primaryTarget = nil, + lastPrimaryTargetBearing = nil, + lastSummaryTime = 0, + hasBeenWelcomed = false, + lastComplimentTime = 0, + lastHeadOnWarningTime = 0, + enableAirScanning = PILOT_INTUITION_CONFIG.enableAirScanning, + enableGroundScanning = PILOT_INTUITION_CONFIG.enableGroundScanning, + scannedGroundTargets = {}, -- List of recently scanned ground targets for selection + cachedWingmen = 0, -- Cached wingmen count + lastIlluminationTime = 0, -- Cooldown tracking for illumination flares + illuminationFlares = PILOT_INTUITION_CONFIG.illuminationFlaresDefault, -- Remaining illumination flares + lastWingmenUpdate = 0, + previousWingmen = 0, -- For formation change detection + frequencyMultiplier = 1.0, -- Per-player alert frequency: 1.0=normal, 2.0=quiet, 0.5=verbose + threateningBandits = {}, -- Reusable table for detected threats + lastMultipleBanditsWarningTime = 0, -- Cooldown for "Multiple bandits in vicinity!" message + distanceUnit = PILOT_INTUITION_CONFIG.distanceUnit, -- Player's distance unit preference + language = PILOT_INTUITION_CONFIG.defaultLanguage, -- Player's language preference + } + end + + -- Menu creation now handled by Birth event only + end + end + + -- If no active players, skip the scan + if #activeClients == 0 then + env.info("PilotIntuition: No active players, skipping scan") + return + end + + -- Cache wingmen for each player (updated every scan for simplicity) + for _, info in ipairs(activeClients) do + local playerName = info.name + local playerData = self.players[playerName] + if playerData then + playerData.cachedWingmen = 0 + + -- Count other players in formation + for _, otherInfo in ipairs(activeClients) do + local u = otherInfo.unit + if u and u:IsAlive() and u:GetCoalition() == info.unit:GetCoalition() and u:GetName() ~= info.unit:GetName() then + local dist = info.pos:Get2DDistance(otherInfo.pos) + if dist <= PILOT_INTUITION_CONFIG.formationRange then + playerData.cachedWingmen = playerData.cachedWingmen + 1 + end + end + end + + -- Count AI wingmen if enabled (aircraft only, same coalition) + if PILOT_INTUITION_CONFIG.countAIWingmen then + PILog(LOG_INFO, "PilotIntuition: Checking AI wingmen for player " .. playerName) + + -- Get all friendly AIR units in formation range + local playerCoalition = info.unit:GetCoalition() + local allFriendlyUnits = coalition.getGroups(playerCoalition, Group.Category.AIRPLANE) + local aiCount = 0 + + for _, dcsGroup in ipairs(allFriendlyUnits) do + local mooseGroup = GROUP:Find(dcsGroup) + if mooseGroup and mooseGroup:IsAlive() then + local groupUnits = mooseGroup:GetUnits() + for _, aiUnit in pairs(groupUnits) do + if aiUnit and aiUnit:IsAlive() and aiUnit:GetName() ~= info.unit:GetName() then + -- Verify it's an aircraft (double-check since we filtered by category) + if aiUnit:IsAir() then + -- Check distance first (cheaper than GetPlayerName) + local aiCoord = aiUnit:GetCoordinate() + if aiCoord then + local dist = info.pos:Get2DDistance(aiCoord) + if dist <= PILOT_INTUITION_CONFIG.formationRange then + -- Now check if it's AI (not a player) + local playerName = aiUnit:GetPlayerName() + local isAI = not playerName or playerName == "" + if isAI then + -- Add AI with multiplier (can be fractional) + playerData.cachedWingmen = playerData.cachedWingmen + PILOT_INTUITION_CONFIG.aiWingmenMultiplier + aiCount = aiCount + 1 + PILog(LOG_INFO, "PilotIntuition: Added AI wingman " .. aiUnit:GetName() .. " at " .. math.floor(dist) .. "m, total: " .. playerData.cachedWingmen) + end + end + end + end + end + end + end + end + + PILog(LOG_INFO, "PilotIntuition: Total AI wingmen in formation: " .. aiCount) + end + + PILog(LOG_INFO, "PilotIntuition: Final wingmen count for " .. playerName .. ": " .. playerData.cachedWingmen) + playerData.lastWingmenUpdate = timer.getTime() + end + end + + -- Send welcome message to new players + for _, info in ipairs(activeClients) do + local playerName = info.name + if self.players[playerName] and not self.players[playerName].hasBeenWelcomed and PILOT_INTUITION_CONFIG.showWelcomeMessage then + MESSAGE:New(self:GetRandomMessage("welcome", nil, playerName), 10):ToClient(info.client) + self.players[playerName].hasBeenWelcomed = true + end + end + + -- Clean up stale player data + for playerName, _ in pairs(self.players) do + if not activePlayerNames[playerName] then + self.players[playerName] = nil + end + end + + -- Build enemy unit lists once per coalition for the scan + local enemyAirByCoalition = {} + enemyAirByCoalition[coalition.side.BLUE] = {} + enemyAirByCoalition[coalition.side.RED] = {} + PILog(LOG_INFO, "PilotIntuition: Building enemy air unit list from _DATABASE.UNITS...") + local enemyCount = 0 + + -- Iterate directly through Moose's already-populated database + for unitName, unitObj in pairs(_DATABASE.UNITS) do + if unitObj and unitObj:IsAlive() and unitObj:IsAir() then + local c = unitObj:GetCoalition() + if c == coalition.side.BLUE or c == coalition.side.RED then + enemyAirByCoalition[c][#enemyAirByCoalition[c] + 1] = unitObj + enemyCount = enemyCount + 1 + PILog(LOG_INFO, "PilotIntuition: Found enemy air unit: " .. unitName .. " (coalition " .. c .. ")") + end + end + end + PILog(LOG_INFO, "PilotIntuition: Total enemy air units found: " .. enemyCount) + + local enemyGroundByCoalition = {} + enemyGroundByCoalition[coalition.side.BLUE] = {} + enemyGroundByCoalition[coalition.side.RED] = {} + + -- Iterate directly through Moose's already-populated database for ground groups + for groupName, groupObj in pairs(_DATABASE.GROUPS) do + if groupObj and groupObj:IsAlive() and groupObj:IsGround() then + local c = groupObj:GetCoalition() + if c == coalition.side.BLUE or c == coalition.side.RED then + enemyGroundByCoalition[c][#enemyGroundByCoalition[c] + 1] = groupObj + end + end + end + + -- Now scan per active client + for _, info in ipairs(activeClients) do + local client = info.client + local unit = info.unit + local playerName = info.name + local playerData = self.players[playerName] + if playerData and unit and unit:IsAlive() then + -- Determine enemy lists for this player + local playerCoal = unit:GetCoalition() + local enemyCoal = (playerCoal == coalition.side.BLUE) and coalition.side.RED or coalition.side.BLUE + PILog(LOG_INFO, "PilotIntuition: Player " .. playerName .. " coalition: " .. playerCoal .. ", enemy coalition: " .. enemyCoal) + PILog(LOG_INFO, "PilotIntuition: Air scanning enabled: " .. tostring(playerData.enableAirScanning)) + PILog(LOG_INFO, "PilotIntuition: Enemy air units for this player: " .. #(enemyAirByCoalition[enemyCoal] or {})) + if playerData.enableAirScanning then + self:ScanAirTargetsForPlayer(unit, playerData, client, activeClients, enemyAirByCoalition[enemyCoal]) + end + if playerData.enableGroundScanning then + self:ScanGroundTargetsForPlayer(unit, client, activeClients, enemyGroundByCoalition[enemyCoal]) + end + self:CheckCloseFlyingForPlayer(unit, playerData, client, activeClients) + end + end + + -- Prune dead ground targets globally + for id, data in pairs(self.trackedGroundTargets) do + if not data.group:IsAlive() then + self.trackedGroundTargets[id] = nil + end + end + + -- Periodic deep cleanup every 60 seconds + local now = timer.getTime() + if not self.lastDeepCleanup or (now - self.lastDeepCleanup) > 60 then + self:DeepCleanup() + self.lastDeepCleanup = now + end + + local endTime = timer.getTime() -- Profiling end + PILog(LOG_DEBUG, string.format("PilotIntuition: ScanTargets completed in %.3f seconds", endTime - startTime)) +end + +function PilotIntuition:DeepCleanup() + env.info("PilotIntuition: Performing deep cleanup") + -- Force remove any stale tracked targets + for playerName, playerData in pairs(self.players) do + for id, data in pairs(playerData.trackedAirTargets) do + if not data.unit or not data.unit:IsAlive() then + playerData.trackedAirTargets[id] = nil + end + end + for id, _ in pairs(playerData.trackedGroundTargets) do + if not self.trackedGroundTargets[id] or not self.trackedGroundTargets[id].group:IsAlive() then + playerData.trackedGroundTargets[id] = nil + end + end + end + -- Clean global ground targets + for id, data in pairs(self.trackedGroundTargets) do + if not data.group:IsAlive() then + self.trackedGroundTargets[id] = nil + end + end + env.info("PilotIntuition: Deep cleanup completed") +end + +-- Setup the mission and per-player menus for pilot intuition toggles +function PilotIntuition:SetupMenu() + PILog(LOG_INFO, "PilotIntuition: SetupMenu called - creating menus for existing players") + + -- Create menus for any players already in the mission (e.g., mission editor start) + SCHEDULER:New(nil, function() + local activePlayers = self:GetActivePlayers() + for clientName, unit in pairs(activePlayers) do + if unit and unit:IsAlive() then + local group = unit:GetGroup() + if group then + local groupName = group:GetName() + if not self.playerMenus[groupName] then + PILog(LOG_INFO, "PilotIntuition: Creating menu for existing player group: " .. groupName) + self.playerMenus[groupName] = self:BuildGroupMenus(group) + MESSAGE:New(self:GetText("systemActive", group:GetUnit(1):GetPlayerName()), 10):ToGroup(group) + end + end + end + end + end, {}, 2) -- Wait 2 seconds for mission to fully load +end + +-- BuildGroupMenus: Creates full menu tree for a player group (CTLD-style) +function PilotIntuition:BuildGroupMenus(group) + PILog(LOG_INFO, "PilotIntuition: BuildGroupMenus called for group: " .. group:GetName()) + + -- Verify group is valid and has units + if not group or not group:IsAlive() then + PILog(LOG_ERROR, "PilotIntuition: Cannot create menu - group is nil or not alive") + return nil + end + + -- Get first unit in group for callbacks and language + local unit = group:GetUnit(1) + if not unit then + PILog(LOG_ERROR, "PilotIntuition: WARNING - No unit found in group") + return nil + end + + local playerKey = self:GetPlayerDataKey(unit) + + -- Create the main menu for this group with translated text + local playerSubMenu = MENU_GROUP:New(group, self:GetText("menu.mainTitle", playerKey)) + PILog(LOG_INFO, "PilotIntuition: Main menu created for group: " .. group:GetName()) + + -- Dogfight Assist submenu + local dogfightMenu = MENU_GROUP:New(group, self:GetText("menu.dogfightAssist", playerKey), playerSubMenu) + MENU_GROUP_COMMAND:New(group, self:GetText("menu.enable", playerKey), dogfightMenu, function() self:MenuSetPlayerDogfightAssist(unit, true) end) + MENU_GROUP_COMMAND:New(group, self:GetText("menu.disable", playerKey), dogfightMenu, function() self:MenuSetPlayerDogfightAssist(unit, false) end) + + -- Marker Type submenu + local markerMenu = MENU_GROUP:New(group, self:GetText("menu.markerType", playerKey), playerSubMenu) + local smokeMenu = MENU_GROUP:New(group, self:GetText("menu.smoke", playerKey), markerMenu) + MENU_GROUP_COMMAND:New(group, self:GetText("menu.red", playerKey), smokeMenu, function() self:MenuSetPlayerMarker(unit, "smoke_red") end) + MENU_GROUP_COMMAND:New(group, self:GetText("menu.green", playerKey), smokeMenu, function() self:MenuSetPlayerMarker(unit, "smoke_green") end) + MENU_GROUP_COMMAND:New(group, self:GetText("menu.blue", playerKey), smokeMenu, function() self:MenuSetPlayerMarker(unit, "smoke_blue") end) + MENU_GROUP_COMMAND:New(group, self:GetText("menu.white", playerKey), smokeMenu, function() self:MenuSetPlayerMarker(unit, "smoke_white") end) + local flareMenu = MENU_GROUP:New(group, self:GetText("menu.flare", playerKey), markerMenu) + MENU_GROUP_COMMAND:New(group, self:GetText("menu.red", playerKey), flareMenu, function() self:MenuSetPlayerMarker(unit, "flare_red") end) + MENU_GROUP_COMMAND:New(group, self:GetText("menu.green", playerKey), flareMenu, function() self:MenuSetPlayerMarker(unit, "flare_green") end) + MENU_GROUP_COMMAND:New(group, self:GetText("menu.white", playerKey), flareMenu, function() self:MenuSetPlayerMarker(unit, "flare_white") end) + MENU_GROUP_COMMAND:New(group, self:GetText("menu.none", playerKey), markerMenu, function() self:MenuSetPlayerMarker(unit, "none") end) + + -- Air scanning submenu + local airScanMenu = MENU_GROUP:New(group, self:GetText("menu.airScanning", playerKey), playerSubMenu) + MENU_GROUP_COMMAND:New(group, self:GetText("menu.enable", playerKey), airScanMenu, function() self:MenuSetPlayerAirScanning(unit, true) end) + MENU_GROUP_COMMAND:New(group, self:GetText("menu.disable", playerKey), airScanMenu, function() self:MenuSetPlayerAirScanning(unit, false) end) + + -- Ground scanning submenu + local groundScanMenu = MENU_GROUP:New(group, self:GetText("menu.groundScanning", playerKey), playerSubMenu) + MENU_GROUP_COMMAND:New(group, self:GetText("menu.enable", playerKey), groundScanMenu, function() self:MenuSetPlayerGroundScanning(unit, true) end) + MENU_GROUP_COMMAND:New(group, self:GetText("menu.disable", playerKey), groundScanMenu, function() self:MenuSetPlayerGroundScanning(unit, false) end) + + -- Ground targeting submenu + local groundMenu = MENU_GROUP:New(group, self:GetText("menu.groundTargeting", playerKey), playerSubMenu) + MENU_GROUP_COMMAND:New(group, self:GetText("menu.scanForTargets", playerKey), groundMenu, function() self:MenuScanGroundTargets(unit) end) + local markMenu = MENU_GROUP:New(group, self:GetText("menu.markTarget", playerKey), groundMenu) + for i=1,5 do + local captureIndex = i + local targetLabel = string.format(self:GetText("menu.target", playerKey), i) + MENU_GROUP_COMMAND:New(group, targetLabel, markMenu, function() self:MenuMarkGroundTarget(unit, captureIndex) end) + end + + -- Illumination submenu with dynamic count display + local playerName = unit:GetPlayerName() or unit:GetName() + local flareCount = (self.players[playerName] and self.players[playerName].illuminationFlares) or PILOT_INTUITION_CONFIG.illuminationFlaresDefault + local illuLabel = string.format(self:GetText("menu.illumination", playerKey), flareCount) + local illuMenu = MENU_GROUP:New(group, illuLabel, playerSubMenu) + MENU_GROUP_COMMAND:New(group, self:GetText("menu.dropAtMyPosition", playerKey), illuMenu, function() self:MenuDropIlluminationAtPlayer(unit) end) + local illuTargetMenu = MENU_GROUP:New(group, self:GetText("menu.dropOnTarget", playerKey), illuMenu) + for i=1,5 do + local captureIndex = i + local targetLabel = string.format(self:GetText("menu.target", playerKey), i) + MENU_GROUP_COMMAND:New(group, targetLabel, illuTargetMenu, function() self:MenuDropIlluminationOnTarget(unit, captureIndex) end) + end + + -- Alert frequency submenu + local freqMenu = MENU_GROUP:New(group, self:GetText("menu.alertFrequency", playerKey), playerSubMenu) + MENU_GROUP_COMMAND:New(group, self:GetText("menu.normalFreq", playerKey), freqMenu, function() self:MenuSetPlayerAlertFrequency(unit, "normal") end) + MENU_GROUP_COMMAND:New(group, self:GetText("menu.quietFreq", playerKey), freqMenu, function() self:MenuSetPlayerAlertFrequency(unit, "quiet") end) + MENU_GROUP_COMMAND:New(group, self:GetText("menu.verboseFreq", playerKey), freqMenu, function() self:MenuSetPlayerAlertFrequency(unit, "verbose") end) + + -- Summary submenu + local summaryMenu = MENU_GROUP:New(group, self:GetText("menu.summary", playerKey), playerSubMenu) + MENU_GROUP_COMMAND:New(group, self:GetText("menu.brief", playerKey), summaryMenu, function() self:MenuSendPlayerSummary(unit, "brief") end) + MENU_GROUP_COMMAND:New(group, self:GetText("menu.detailed", playerKey), summaryMenu, function() self:MenuSendPlayerSummary(unit, "detailed") end) + + -- Admin Settings submenu (placed last) + local adminMenu = MENU_GROUP:New(group, self:GetText("menu.settingsAndGuides", playerKey), playerSubMenu) + + -- Language selection submenu (under admin) - Keep multilingual for accessibility + local langMenu = MENU_GROUP:New(group, "Language / Sprache / Langue", adminMenu) + MENU_GROUP_COMMAND:New(group, self:GetText("menu.english", playerKey), langMenu, function() self:MenuSetPlayerLanguage(unit, "EN") end) + MENU_GROUP_COMMAND:New(group, self:GetText("menu.german", playerKey), langMenu, function() self:MenuSetPlayerLanguage(unit, "DE") end) + MENU_GROUP_COMMAND:New(group, self:GetText("menu.french", playerKey), langMenu, function() self:MenuSetPlayerLanguage(unit, "FR") end) + MENU_GROUP_COMMAND:New(group, self:GetText("menu.spanish", playerKey), langMenu, function() self:MenuSetPlayerLanguage(unit, "ES") end) + MENU_GROUP_COMMAND:New(group, self:GetText("menu.russian", playerKey), langMenu, function() self:MenuSetPlayerLanguage(unit, "RU") end) + + -- Distance units submenu (under admin) + local distMenu = MENU_GROUP:New(group, self:GetText("menu.distanceUnits", playerKey), adminMenu) + MENU_GROUP_COMMAND:New(group, self:GetText("menu.milesNautical", playerKey), distMenu, function() self:MenuSetPlayerDistanceUnit(unit, "mi") end) + MENU_GROUP_COMMAND:New(group, self:GetText("menu.kilometers", playerKey), distMenu, function() self:MenuSetPlayerDistanceUnit(unit, "km") end) + + -- Player Guide submenu + local guideMenu = MENU_GROUP:New(group, self:GetText("menu.playerGuide", playerKey), adminMenu) + MENU_GROUP_COMMAND:New(group, self:GetText("menu.systemOverview", playerKey), guideMenu, function() self:ShowGuideOverview(group) end) + MENU_GROUP_COMMAND:New(group, self:GetText("menu.detectionRanges", playerKey), guideMenu, function() self:ShowGuideRanges(group) end) + MENU_GROUP_COMMAND:New(group, self:GetText("menu.formationTips", playerKey), guideMenu, function() self:ShowGuideFormation(group) end) + MENU_GROUP_COMMAND:New(group, "Environment Effects", guideMenu, function() self:ShowGuideEnvironment(group) end) + + -- Log level submenu (global setting) + local logMenu = MENU_GROUP:New(group, self:GetText("menu.logLevel", playerKey), adminMenu) + MENU_GROUP_COMMAND:New(group, "Error Only", logMenu, function() self:SetLogLevel(1, group) end) + MENU_GROUP_COMMAND:New(group, "Info (Default)", logMenu, function() self:SetLogLevel(2, group) end) + MENU_GROUP_COMMAND:New(group, "Debug", logMenu, function() self:SetLogLevel(3, group) end) + MENU_GROUP_COMMAND:New(group, "Trace (Verbose)", logMenu, function() self:SetLogLevel(4, group) end) + + PILog(LOG_INFO, "PilotIntuition: Menu created successfully for group " .. group:GetName()) + return playerSubMenu +end + +-- Rebuild menu for a player group (used when illumination flare count changes) +function PilotIntuition:RebuildPlayerMenu(unit) + if not unit or not unit:IsAlive() then + PILog(LOG_ERROR, "PilotIntuition: RebuildPlayerMenu - invalid unit") + return + end + + local group = unit:GetGroup() + if not group then + PILog(LOG_ERROR, "PilotIntuition: RebuildPlayerMenu - no group found") + return + end + + local groupName = group:GetName() + PILog(LOG_DEBUG, "PilotIntuition: Rebuilding menu for group: " .. groupName) + + -- Remove old menu if it exists + if self.playerMenus[groupName] then + local oldMenu = self.playerMenus[groupName] + if oldMenu and oldMenu.Remove then + oldMenu:Remove() + PILog(LOG_DEBUG, "PilotIntuition: Removed old menu for group: " .. groupName) + end + self.playerMenus[groupName] = nil + end + + -- Build new menu + self.playerMenus[groupName] = self:BuildGroupMenus(group) + PILog(LOG_INFO, "PilotIntuition: Menu rebuilt successfully for group: " .. groupName) +end + +function PilotIntuition:SetLogLevel(level, group) + PILOT_INTUITION_LOG_LEVEL = level + local labels = {"None", "Error", "Info", "Debug", "Trace"} + MESSAGE:New("Log level set to: " .. (labels[level + 1] or "Unknown"), 5):ToGroup(group) + PILog(LOG_INFO, "PilotIntuition: Log level changed to " .. level) +end + +-- Player Guide functions +function PilotIntuition:ShowGuideOverview(group) + local msg = "WWII PILOT INTUITION - SYSTEM OVERVIEW\n\n" + msg = msg .. "This system simulates WWII-era pilot awareness without modern radar or labels.\n\n" + msg = msg .. "KEY FEATURES:\n" + msg = msg .. "• Air & Ground target detection based on visual ranges\n" + msg = msg .. "• Formation flying SIGNIFICANTLY boosts detection ranges\n" + msg = msg .. "• Dogfight assistance (merge warnings, tail alerts, energy state)\n" + msg = msg .. "• Optional smoke/flare markers for spotted targets\n" + msg = msg .. "• Environmental effects (night, weather reduce ranges)\n\n" + msg = msg .. "Use F10 menu to customize settings per pilot.\n" + msg = msg .. "Check other guide sections for detailed mechanics." + MESSAGE:New(msg, 20):ToGroup(group) +end + +function PilotIntuition:ShowGuideRanges(group) + local airRange = PILOT_INTUITION_CONFIG.airDetectionRange + local groundRange = PILOT_INTUITION_CONFIG.groundDetectionRange + local maxMult = PILOT_INTUITION_CONFIG.maxMultiplier + local formRange = PILOT_INTUITION_CONFIG.formationRange + + local msg = "DETECTION RANGES\n\n" + msg = msg .. "BASE RANGES (Solo Flight):\n" + msg = msg .. "• Air Targets: " .. airRange .. "m (~" .. math.floor(airRange / 1852 * 10) / 10 .. "nm)\n" + msg = msg .. "• Ground Targets: " .. groundRange .. "m (~" .. math.floor(groundRange / 1852 * 10) / 10 .. "nm)\n\n" + msg = msg .. "FORMATION MULTIPLIERS:\n" + msg = msg .. "Solo: 1.0x (base range)\n" + msg = msg .. "2-ship: 2.0x (double range)\n" + msg = msg .. "3-ship: 3.0x (triple range)\n" + msg = msg .. "4-ship: 4.0x (quadruple range)\n" + if maxMult >= 5 then + msg = msg .. "5-ship: 5.0x\n" + end + if maxMult >= 6 then + msg = msg .. "6+ ship: " .. maxMult .. ".0x (maximum)\n\n" + else + msg = msg .. maxMult .. "+ ship: " .. maxMult .. ".0x (maximum)\n\n" + end + msg = msg .. "EXAMPLE (Air Detection):\n" + local solo = math.floor(airRange / 1000) + local twoship = math.floor(airRange * 2 / 1000) + local fourship = math.floor(airRange * math.min(4, maxMult) / 1000) + msg = msg .. "Solo: " .. solo .. "km | 2-ship: " .. twoship .. "km | 4-ship: " .. fourship .. "km\n\n" + msg = msg .. "Formation = wingmen within " .. formRange .. "m (~" .. math.floor(formRange / 1852 * 10) / 10 .. "nm)" + MESSAGE:New(msg, 25):ToGroup(group) +end + +function PilotIntuition:ShowGuideFormation(group) + local formRange = PILOT_INTUITION_CONFIG.formationRange + local maxMult = PILOT_INTUITION_CONFIG.maxMultiplier + local minWingmen = PILOT_INTUITION_CONFIG.minFormationWingmen + + local msg = "FORMATION FLYING BENEFITS\n\n" + msg = msg .. "Formation flying is CRITICAL for situational awareness!\n\n" + msg = msg .. "FORMATION REQUIREMENTS:\n" + msg = msg .. "• Wingmen must be within " .. formRange .. "m (~" .. math.floor(formRange / 1852 * 10) / 10 .. "nm)\n" + msg = msg .. "• Same coalition (friendlies only)\n" + msg = msg .. "• Aircraft must be alive and player-controlled\n" + msg = msg .. "• Minimum wingmen for warnings: " .. minWingmen .. "\n\n" + msg = msg .. "DETECTION BOOST:\n" + msg = msg .. "Each additional wingman adds 1.0x to your detection range (up to " .. maxMult .. ".0x max).\n\n" + msg = msg .. "TACTICAL ADVANTAGE:\n" + local fourShipMult = math.min(4, maxMult) + msg = msg .. "• 4-ship formation sees " .. fourShipMult .. "x further than solo\n" + msg = msg .. "• Spot bandits before they spot you\n" + msg = msg .. "• Better ground reconnaissance coverage\n" + MESSAGE:New(msg, 25):ToGroup(group) +end + +function PilotIntuition:ShowGuideEnvironment(group) + local nightMult = PILOT_INTUITION_CONFIG.nightDetectionMultiplier + local weatherMult = PILOT_INTUITION_CONFIG.badWeatherMultiplier + local mergeRange = PILOT_INTUITION_CONFIG.mergeRange + local tailRange = PILOT_INTUITION_CONFIG.tailWarningRange + local headOnRange = PILOT_INTUITION_CONFIG.headOnRange + local beamRange = PILOT_INTUITION_CONFIG.beamRange + local combinedMult = nightMult * weatherMult + + local msg = "ENVIRONMENT EFFECTS\n\n" + msg = msg .. "Detection ranges are affected by conditions:\n\n" + msg = msg .. "NIGHT TIME:\n" + msg = msg .. "• " .. (nightMult * 100) .. "% detection range\n" + msg = msg .. "• Harder to spot targets in darkness\n" + msg = msg .. "• Formation flying still helps!\n\n" + msg = msg .. "BAD WEATHER:\n" + msg = msg .. "• " .. (weatherMult * 100) .. "% detection range\n" + msg = msg .. "• Rain, fog, clouds reduce visibility\n" + msg = msg .. "• Stacks with night penalty\n\n" + msg = msg .. "COMBINED EFFECTS:\n" + msg = msg .. "Night + Bad Weather = ~" .. math.floor(combinedMult * 100) .. "% of normal range.\n\n" + msg = msg .. "DOGFIGHT RANGES:\n" + msg = msg .. "• Merge: " .. mergeRange .. "m\n" + msg = msg .. "• Tail Warning: " .. tailRange .. "m\n" + msg = msg .. "• Head-On: " .. headOnRange .. "m\n" + msg = msg .. "• Beam: " .. beamRange .. "m\n\n" + msg = msg .. "These ranges are NOT affected by environment - they're visual merge distances." + MESSAGE:New(msg, 30):ToGroup(group) +end + +-- WireEventHandlers: Set up Birth event handler (CTLD-style) +function PilotIntuition:WireEventHandlers() + PILog(LOG_INFO, "PilotIntuition: Wiring event handlers using EVENTHANDLER") + + local handler = EVENTHANDLER:New() + handler:HandleEvent(EVENTS.Birth) + handler:HandleEvent(EVENTS.PlayerEnterUnit) + handler:HandleEvent(EVENTS.Shot) + handler:HandleEvent(EVENTS.Land) + + local selfref = self + + function handler:OnEventBirth(EventData) + if not EventData or not EventData.IniUnit then return end + local unit = EventData.IniUnit + if not unit or not unit:IsAlive() then return end + + -- Only create menus for player-controlled units + local playerName = unit:GetPlayerName() + if not playerName then + PILog(LOG_TRACE, "PilotIntuition: Birth event for non-player unit, skipping menu creation") + return + end + + local group = unit:GetGroup() + if not group then return end + local groupName = group:GetName() + + -- Simple check: if menu exists for this group, skip + if selfref.playerMenus[groupName] then + PILog(LOG_DEBUG, "PilotIntuition: Menu already exists for group: " .. groupName) + return + end + + PILog(LOG_INFO, "PilotIntuition: Birth event - creating menu for player group: " .. groupName) + selfref.playerMenus[groupName] = selfref:BuildGroupMenus(group) + + -- Send welcome message to group + MESSAGE:New("WWII Pilot Intuition active! Use F10 menu for settings.", 10):ToGroup(group) + end + + function handler:OnEventPlayerEnterUnit(EventData) + if not EventData or not EventData.IniUnit then return end + local unit = EventData.IniUnit + if not unit or not unit:IsAlive() then return end + + local group = unit:GetGroup() + if not group then return end + local groupName = group:GetName() + + PILog(LOG_INFO, "PilotIntuition: PlayerEnterUnit event for group: " .. groupName) + + -- Add a small delay to ensure the unit is fully initialized in multiplayer + SCHEDULER:New(nil, function() + -- Check if menu already exists + if not selfref.playerMenus[groupName] then + PILog(LOG_INFO, "PilotIntuition: Creating menu for player group: " .. groupName) + selfref.playerMenus[groupName] = selfref:BuildGroupMenus(group) + MESSAGE:New("WWII Pilot Intuition active! Use F10 menu for settings.", 10):ToGroup(group) + else + PILog(LOG_DEBUG, "PilotIntuition: Menu already exists for group: " .. groupName) + end + end, {}, 1) + end + + function handler:OnEventShot(EventData) + selfref:OnPlayerShot(EventData) + selfref:OnShotFired(EventData) + end + + function handler:OnEventLand(EventData) + if not EventData or not EventData.IniUnit then return end + local unit = EventData.IniUnit + if not unit or not unit:IsAlive() then return end + + -- Check if it's a player unit + local playerName = unit:GetPlayerName() + if not playerName then return end + + local playerData = selfref.players[playerName] + if not playerData then return end + + -- Check if landed at a friendly airbase + local place = EventData.Place + if place and place:getCoalition() == unit:GetCoalition() then + -- Rearm illumination flares + local prevCount = playerData.illuminationFlares or 0 + playerData.illuminationFlares = PILOT_INTUITION_CONFIG.illuminationFlaresDefault + + local client = unit:GetClient() + if client and prevCount < PILOT_INTUITION_CONFIG.illuminationFlaresDefault then + MESSAGE:New(string.format(self:GetText("illuminationRearmed", clientName), PILOT_INTUITION_CONFIG.illuminationFlaresDefault), 10):ToClient(client) + end + + PILog(LOG_INFO, "PilotIntuition: Player " .. playerName .. " rearmed illumination flares at friendly airbase") + end + end + + self.EventHandler = handler + PILog(LOG_INFO, "PilotIntuition: Event handlers wired successfully") +end + +function PilotIntuition:OnPlayerEnterUnit(EventData) + env.info("PilotIntuition: OnPlayerEnterUnit event triggered") + if EventData and EventData.IniUnit then + local unit = EventData.IniUnit + if unit and unit:IsAlive() then + env.info("PilotIntuition: Player entered unit: " .. tostring(unit:GetName())) + -- Small delay to ensure unit is fully initialized + SCHEDULER:New(nil, function() + self:SetupPlayerMenus() + end, {}, 2) + end + end +end + +function PilotIntuition:SetupPlayerMenus() + env.info("PilotIntuition: SetupPlayerMenus called") + + local activePlayers = self:GetActivePlayers() + local playersFound = 0 + + for clientName, unit in pairs(activePlayers) do + playersFound = playersFound + 1 + env.info("PilotIntuition: Processing player: " .. tostring(clientName)) + + if unit and unit:IsAlive() then + env.info("PilotIntuition: Unit is alive: " .. tostring(unit:GetName())) + + -- Get the actual player name for aliasing + local actualPlayerName = unit:GetPlayerName() or unit:GetName() + + -- Ensure player data exists (may have been created by ScanTargets) + if not self.players[clientName] then + self.players[clientName] = { + trackedAirTargets = {}, + lastMessageTime = timer.getTime(), + lastDogfightTime = 0, -- For multi-bandit tactical picture cooldown + wingmenList = {}, + formationWarned = false, + lastFormationChangeTime = 0, + lastConclusionTime = 0, + trackedGroundTargets = {}, + dogfightAssist = PILOT_INTUITION_CONFIG.dogfightAssistEnabled, + lastDogfightAssistTime = 0, + markerType = PILOT_INTUITION_CONFIG.markerType, + lastSpeed = 0, + primaryTarget = nil, + lastPrimaryTargetBearing = nil, + lastSummaryTime = 0, + hasBeenWelcomed = false, + lastComplimentTime = 0, + lastHeadOnWarningTime = 0, + enableAirScanning = PILOT_INTUITION_CONFIG.enableAirScanning, + enableGroundScanning = PILOT_INTUITION_CONFIG.enableGroundScanning, + scannedGroundTargets = {}, + cachedWingmen = 0, + lastWingmenUpdate = 0, + previousWingmen = 0, + frequencyMultiplier = 1.0, + threateningBandits = {}, -- Reusable table for detected threats + distanceUnit = PILOT_INTUITION_CONFIG.distanceUnit, + language = PILOT_INTUITION_CONFIG.defaultLanguage, + } + end + + -- Create alias for actual player name + if actualPlayerName ~= clientName then + env.info("PilotIntuition: Creating player alias in SetupPlayerMenus: '" .. actualPlayerName .. "' -> '" .. clientName .. "'") + self.players[actualPlayerName] = self.players[clientName] + end + + local playerGroup = unit:GetGroup() + if not playerGroup then + env.info("PilotIntuition: No group found for unit " .. tostring(unit:GetName())) + else + -- Skip if menu already created for this player + if self.playerMenus[clientName] then + env.info("PilotIntuition: Menu already exists for " .. clientName) + else + env.info("PilotIntuition: Creating menu for player " .. clientName) + + -- Create the main menu for this player's group + local playerSubMenu = MENU_GROUP:New(playerGroup, "WWII Pilot Intuition") + local currentGroupName = playerGroup:GetName() + self.playerMenus[clientName] = { + menu = playerSubMenu, + groupName = currentGroupName + } + + -- Dogfight Assist submenu + local dogfightMenu = MENU_GROUP:New(playerGroup, "Dogfight Assist", playerSubMenu) + MENU_GROUP_COMMAND:New(playerGroup, "Enable", dogfightMenu, function() self:MenuSetPlayerDogfightAssist(unit, true) end) + MENU_GROUP_COMMAND:New(playerGroup, "Disable", dogfightMenu, function() self:MenuSetPlayerDogfightAssist(unit, false) end) + + -- Marker Type submenu + local markerMenu = MENU_GROUP:New(playerGroup, "Marker Type", playerSubMenu) + local smokeMenu = MENU_GROUP:New(playerGroup, "Smoke", markerMenu) + MENU_GROUP_COMMAND:New(playerGroup, "Red", smokeMenu, function() self:MenuSetPlayerMarker(unit, "smoke_red") end) + MENU_GROUP_COMMAND:New(playerGroup, "Green", smokeMenu, function() self:MenuSetPlayerMarker(unit, "smoke_green") end) + MENU_GROUP_COMMAND:New(playerGroup, "Blue", smokeMenu, function() self:MenuSetPlayerMarker(unit, "smoke_blue") end) + MENU_GROUP_COMMAND:New(playerGroup, "White", smokeMenu, function() self:MenuSetPlayerMarker(unit, "smoke_white") end) + local flareMenu = MENU_GROUP:New(playerGroup, "Flare", markerMenu) + MENU_GROUP_COMMAND:New(playerGroup, "Red", flareMenu, function() self:MenuSetPlayerMarker(unit, "flare_red") end) + MENU_GROUP_COMMAND:New(playerGroup, "Green", flareMenu, function() self:MenuSetPlayerMarker(unit, "flare_green") end) + MENU_GROUP_COMMAND:New(playerGroup, "White", flareMenu, function() self:MenuSetPlayerMarker(unit, "flare_white") end) + MENU_GROUP_COMMAND:New(playerGroup, "None", markerMenu, function() self:MenuSetPlayerMarker(unit, "none") end) + + -- Air scanning submenu + local airScanMenu = MENU_GROUP:New(playerGroup, "Air Scanning", playerSubMenu) + MENU_GROUP_COMMAND:New(playerGroup, "Enable", airScanMenu, function() self:MenuSetPlayerAirScanning(unit, true) end) + MENU_GROUP_COMMAND:New(playerGroup, "Disable", airScanMenu, function() self:MenuSetPlayerAirScanning(unit, false) end) + + -- Ground scanning submenu + local groundScanMenu = MENU_GROUP:New(playerGroup, "Ground Scanning", playerSubMenu) + MENU_GROUP_COMMAND:New(playerGroup, "Enable", groundScanMenu, function() self:MenuSetPlayerGroundScanning(unit, true) end) + MENU_GROUP_COMMAND:New(playerGroup, "Disable", groundScanMenu, function() self:MenuSetPlayerGroundScanning(unit, false) end) + + -- Ground targeting submenu + local groundMenu = MENU_GROUP:New(playerGroup, "Ground Targeting", playerSubMenu) + MENU_GROUP_COMMAND:New(playerGroup, "Scan for Targets", groundMenu, function() self:MenuScanGroundTargets(unit) end) + local markMenu = MENU_GROUP:New(playerGroup, "Mark Target", groundMenu) + for i=1,5 do + MENU_GROUP_COMMAND:New(playerGroup, "Target " .. i, markMenu, function() self:MenuMarkGroundTarget(unit, i) end) + end + + -- Alert frequency submenu + local freqMenu = MENU_GROUP:New(playerGroup, "Alert Frequency", playerSubMenu) + MENU_GROUP_COMMAND:New(playerGroup, "Normal", freqMenu, function() self:MenuSetPlayerAlertFrequency(unit, "normal") end) + MENU_GROUP_COMMAND:New(playerGroup, "Quiet", freqMenu, function() self:MenuSetPlayerAlertFrequency(unit, "quiet") end) + MENU_GROUP_COMMAND:New(playerGroup, "Verbose", freqMenu, function() self:MenuSetPlayerAlertFrequency(unit, "verbose") end) + + -- Summary submenu + local summaryMenu = MENU_GROUP:New(playerGroup, "Summary", playerSubMenu) + MENU_GROUP_COMMAND:New(playerGroup, "Brief", summaryMenu, function() self:MenuSendPlayerSummary(unit, "brief") end) + MENU_GROUP_COMMAND:New(playerGroup, "Detailed", summaryMenu, function() self:MenuSendPlayerSummary(unit, "detailed") end) + + env.info("PilotIntuition: Menu created successfully for " .. clientName) + end + end + end + end + + env.info("PilotIntuition: SetupPlayerMenus completed, processed " .. playersFound .. " players") +end + +function PilotIntuition:MenuSetPlayerMarker(playerUnit, markerType) + env.info("====== PilotIntuition: MenuSetPlayerMarker CALLED ======") + env.info("PilotIntuition: playerUnit = " .. tostring(playerUnit)) + if playerUnit then + env.info("PilotIntuition: playerUnit:GetName() = " .. tostring(playerUnit:GetName())) + env.info("PilotIntuition: playerUnit:GetPlayerName() = " .. tostring(playerUnit:GetPlayerName())) + end + env.info("PilotIntuition: markerType = " .. tostring(markerType)) + if not playerUnit then + env.info("PilotIntuition: ERROR - No playerUnit provided") + return + end + local playerKey = self:GetPlayerDataKey(playerUnit) + -- store as player pref only - not global config + if playerKey and self.players[playerKey] then + self.players[playerKey].markerType = markerType + local client = playerUnit:GetClient() + if client then + local niceType = markerType:gsub("_", " "):gsub("(%w)(%w*)", function(first, rest) return first:upper() .. rest:lower() end) + MESSAGE:New(self:GetRandomMessage("markerSet", {niceType}, playerName), 10):ToClient(client) + end + end +end +function PilotIntuition:MenuSetMarkerType(type) + env.info("PilotIntuition: MenuSetMarkerType called with " .. tostring(type)) + PILOT_INTUITION_CONFIG.markerType = type + local niceType = type:gsub("_", " "):gsub("(%w)(%w*)", function(first, rest) return first:upper() .. rest:lower() end) + local msg = self:GetRandomMessage("markerSet", {niceType}) + env.info("PilotIntuition: Marker message: " .. msg) + self:BroadcastMessageToAll(msg) +end + +function PilotIntuition:MenuSetPlayerDogfightAssist(playerUnit, onoff) + env.info("====== PilotIntuition: MenuSetPlayerDogfightAssist CALLED ======") + env.info("PilotIntuition: playerUnit = " .. tostring(playerUnit)) + if playerUnit then + env.info("PilotIntuition: playerUnit:GetName() = " .. tostring(playerUnit:GetName())) + end + env.info("PilotIntuition: onoff = " .. tostring(onoff)) + if not playerUnit then + env.info("PilotIntuition: ERROR - No playerUnit provided") + return + end + local playerKey = self:GetPlayerDataKey(playerUnit) + env.info("PilotIntuition: Looking for player data with key: " .. tostring(playerKey)) + env.info("PilotIntuition: Available players: " .. table.concat(self:GetPlayerKeys(), ", ")) + + if playerKey and self.players[playerKey] then + env.info("PilotIntuition: Found player data, setting dogfightAssist to " .. tostring(onoff)) + self.players[playerKey].dogfightAssist = onoff + local status = onoff and "enabled" or "disabled" + local client = playerUnit:GetClient() + env.info("PilotIntuition: Getting client: " .. tostring(client)) + if client then + env.info("PilotIntuition: Sending message to client") + MESSAGE:New(self:GetRandomMessage("dogfightAssistToggle", {status}, playerName), 10):ToClient(client) + else + env.info("PilotIntuition: ERROR - Could not get client") + end + else + env.info("PilotIntuition: ERROR - Player data not found for: " .. tostring(playerKey)) + end +end + +function PilotIntuition:MenuSetPlayerAirScanning(playerUnit, onoff) + env.info("====== PilotIntuition: MenuSetPlayerAirScanning CALLED ======") + env.info("PilotIntuition: playerUnit = " .. tostring(playerUnit)) + if playerUnit then + env.info("PilotIntuition: playerUnit:GetName() = " .. tostring(playerUnit:GetName())) + end + env.info("PilotIntuition: onoff = " .. tostring(onoff)) + if not playerUnit then + env.info("PilotIntuition: ERROR - No playerUnit provided") + return + end + local playerKey = self:GetPlayerDataKey(playerUnit) + if playerKey and self.players[playerKey] then + self.players[playerKey].enableAirScanning = onoff + local status = onoff and "enabled" or "disabled" + local client = playerUnit:GetClient() + if client then + MESSAGE:New(self:GetRandomMessage("airScanningToggle", {status}, playerName), 10):ToClient(client) + end + end +end + +function PilotIntuition:MenuSetPlayerGroundScanning(playerUnit, onoff) + env.info("====== PilotIntuition: MenuSetPlayerGroundScanning CALLED ======") + env.info("PilotIntuition: playerUnit = " .. tostring(playerUnit)) + if playerUnit then + env.info("PilotIntuition: playerUnit:GetName() = " .. tostring(playerUnit:GetName())) + end + env.info("PilotIntuition: onoff = " .. tostring(onoff)) + if not playerUnit then + env.info("PilotIntuition: ERROR - No playerUnit provided") + return + end + local playerKey = self:GetPlayerDataKey(playerUnit) + if playerKey and self.players[playerKey] then + self.players[playerKey].enableGroundScanning = onoff + local status = onoff and "enabled" or "disabled" + local client = playerUnit:GetClient() + if client then + MESSAGE:New(self:GetRandomMessage("groundScanningToggle", {status}, playerName), 10):ToClient(client) + end + end +end + +function PilotIntuition:MenuSetPlayerAlertFrequency(playerUnit, mode) + env.info("====== PilotIntuition: MenuSetPlayerAlertFrequency CALLED ======") + env.info("PilotIntuition: playerUnit = " .. tostring(playerUnit)) + if playerUnit then + env.info("PilotIntuition: playerUnit:GetName() = " .. tostring(playerUnit:GetName())) + end + env.info("PilotIntuition: mode = " .. tostring(mode)) + if not playerUnit then + env.info("PilotIntuition: ERROR - No playerUnit provided") + return + end + local playerKey = self:GetPlayerDataKey(playerUnit) + if playerKey and self.players[playerKey] then + local multiplier + if mode == "normal" then + multiplier = 1.0 + elseif mode == "quiet" then + multiplier = 2.0 + elseif mode == "verbose" then + multiplier = 0.5 + else + multiplier = 1.0 -- Default + end + self.players[playerKey].frequencyMultiplier = multiplier + local client = playerUnit:GetClient() + if client then + MESSAGE:New(self:GetRandomMessage("alertFrequencyToggle", {mode}, playerName), 10):ToClient(client) + end + end +end + +function PilotIntuition:MenuSetPlayerDistanceUnit(playerUnit, unit) + env.info("====== PilotIntuition: MenuSetPlayerDistanceUnit CALLED ======") + env.info("PilotIntuition: playerUnit = " .. tostring(playerUnit)) + if playerUnit then + env.info("PilotIntuition: playerUnit:GetName() = " .. tostring(playerUnit:GetName())) + end + env.info("PilotIntuition: unit = " .. tostring(unit)) + if not playerUnit then + env.info("PilotIntuition: ERROR - No playerUnit provided") + return + end + local playerKey = self:GetPlayerDataKey(playerUnit) + if playerKey and self.players[playerKey] then + self.players[playerKey].distanceUnit = unit + local client = playerUnit:GetClient() + if client then + local unitName = unit == "mi" and "Miles (Nautical)" or "Kilometers" + MESSAGE:New(string.format(self:GetText("distanceUnitsSet", playerKey), unitName), 10):ToClient(client) + end + end +end + +function PilotIntuition:MenuSetPlayerLanguage(playerUnit, language) + env.info("====== PilotIntuition: MenuSetPlayerLanguage CALLED ======") + env.info("PilotIntuition: playerUnit = " .. tostring(playerUnit)) + if playerUnit then + env.info("PilotIntuition: playerUnit:GetName() = " .. tostring(playerUnit:GetName())) + end + env.info("PilotIntuition: language = " .. tostring(language)) + if not playerUnit then + env.info("PilotIntuition: ERROR - No playerUnit provided") + return + end + local playerKey = self:GetPlayerDataKey(playerUnit) + if playerKey and self.players[playerKey] then + -- Validate language + if not PILOT_INTUITION_LANGUAGES[language] then + env.info("PilotIntuition: Invalid language: " .. tostring(language)) + return + end + + self.players[playerKey].language = language + + -- Rebuild the menu immediately to apply language change + self:RebuildPlayerMenu(playerUnit) + + local client = playerUnit:GetClient() + if client then + local langNames = { + EN = "English", + DE = "Deutsch", + FR = "Français", + ES = "Español", + RU = "Русский" + } + local msg = string.format("Language set to %s. Menu has been updated.", langNames[language] or language) + MESSAGE:New(msg, 10):ToClient(client) + end + end +end + +function PilotIntuition:MenuScanGroundTargets(playerUnit) + env.info("====== PilotIntuition: MenuScanGroundTargets CALLED ======") + env.info("PilotIntuition: playerUnit = " .. tostring(playerUnit)) + if playerUnit then + env.info("PilotIntuition: playerUnit:GetName() = " .. tostring(playerUnit:GetName())) + end + if not playerUnit or not playerUnit:IsAlive() then + env.info("PilotIntuition: ERROR - No playerUnit provided or unit not alive") + return + end + + local playerPos = playerUnit:GetCoordinate() + if not playerPos then + env.info("PilotIntuition: ERROR - Cannot get player position") + return + end + + local playerKey = self:GetPlayerDataKey(playerUnit) + local playerData = playerKey and self.players[playerKey] + if not playerData then + env.info("PilotIntuition: ERROR - No player data found for " .. tostring(playerKey)) + return + end + + local client = playerUnit:GetClient() + if not client then + env.info("PilotIntuition: ERROR - No client found") + return + end + + -- Collect enemy ground groups within range + local playerCoalition = playerUnit:GetCoalition() + local enemyCoalition = (playerCoalition == coalition.side.BLUE) and coalition.side.RED or coalition.side.BLUE + local allGroups = coalition.getGroups(enemyCoalition) + local enemyGroundGroups = {} + for _, dcsGroup in ipairs(allGroups) do + -- Wrap DCS group object with Moose GROUP + local group = GROUP:Find(dcsGroup) + if group and group:IsAlive() and (dcsGroup:getCategory() == Group.Category.GROUND or dcsGroup:getCategory() == Group.Category.SHIP) then + local groupCoord = group:GetCoordinate() + if groupCoord then + local distance = playerPos:Get2DDistance(groupCoord) + if distance <= PILOT_INTUITION_CONFIG.groundDetectionRange then + table.insert(enemyGroundGroups, {group = group, distance = distance}) + end + end + end + end + + -- Sort by distance + table.sort(enemyGroundGroups, function(a,b) return a.distance < b.distance end) + + -- Take top 5 + local scanned = {} + for i=1, math.min(5, #enemyGroundGroups) do + scanned[i] = enemyGroundGroups[i].group + end + + -- Store in playerData + playerData.scannedGroundTargets = scanned + + -- Send messages listing targets + for i, group in ipairs(scanned) do + local targetPos = group:GetCoordinate() + local bearing = playerPos:HeadingTo(targetPos) -- Returns heading in degrees as number + + -- Ensure bearing is a valid number + if not bearing or type(bearing) ~= "number" then + bearing = 0 -- Fallback to 0 if bearing is invalid + end + + local distanceMeters = playerPos:Get2DDistance(targetPos) + local distance, unit = self:FormatDistance(distanceMeters, playerKey) + local unitType = group:GetUnits()[1]:GetTypeName() + local category = self:ClassifyGroundUnit(unitType) + local groupSize = #group:GetUnits() + local sizeDesc = groupSize == 1 and "single" or (groupSize <= 4 and "group" or "platoon") + MESSAGE:New(string.format("Target %d: %s %s %s, Bearing %.0f, Range %.1f %s", i, category, sizeDesc, unitType, bearing, distance, unit), 30):ToClient(client) + end + + if #scanned == 0 then + MESSAGE:New(self:GetText("noGroundTargets", playerName), 10):ToClient(client) + else + MESSAGE:New(self:GetText("selectTargetFromMenu", playerName), 10):ToClient(client) + end +end + +function PilotIntuition:MenuMarkGroundTarget(playerUnit, index) + env.info("====== PilotIntuition: MenuMarkGroundTarget CALLED ======") + env.info("PilotIntuition: playerUnit = " .. tostring(playerUnit)) + if playerUnit then + env.info("PilotIntuition: playerUnit:GetName() = " .. tostring(playerUnit:GetName())) + end + env.info("PilotIntuition: index = " .. tostring(index)) + if not playerUnit then + env.info("PilotIntuition: ERROR - No playerUnit provided") + return + end + local playerKey = self:GetPlayerDataKey(playerUnit) + local playerData = playerKey and self.players[playerKey] + if not playerData then + env.info("PilotIntuition: ERROR - No player data found for " .. tostring(playerKey)) + return + end + local client = playerUnit:GetClient() + if not client then + env.info("PilotIntuition: ERROR - No client found") + return + end + + local scanned = playerData.scannedGroundTargets or {} + local group = scanned[index] + if not group or not group:IsAlive() then + MESSAGE:New("Target " .. index .. " not available.", 10):ToClient(client) + return + end + + -- Mark this target + self:ReportGroundTarget(group, playerUnit, client, true) -- placeMarker = true + MESSAGE:New("Marked Target " .. index .. ".", 10):ToClient(client) +end + +function PilotIntuition:MenuDropIlluminationAtPlayer(playerUnit) + env.info("====== PilotIntuition: MenuDropIlluminationAtPlayer CALLED ======") + env.info("PilotIntuition: playerUnit = " .. tostring(playerUnit)) + if playerUnit then + env.info("PilotIntuition: playerUnit:GetName() = " .. tostring(playerUnit:GetName())) + end + if not playerUnit or not playerUnit:IsAlive() then + env.info("PilotIntuition: ERROR - No playerUnit or unit not alive") + return + end + + local playerKey = self:GetPlayerDataKey(playerUnit) + env.info("PilotIntuition: playerKey = " .. tostring(playerKey)) + + if not playerKey or not self.players[playerKey] then + env.info("PilotIntuition: ERROR - Player data not found for: " .. tostring(playerKey)) + return + end + + local playerData = self.players[playerKey] + local client = playerUnit:GetClient() + if not client then + env.info("PilotIntuition: ERROR - Could not get client") + return + end + + -- Check if player has flares remaining + if (playerData.illuminationFlares or 0) <= 0 then + MESSAGE:New("No illumination flares remaining. Land at a friendly airbase to rearm.", 10):ToClient(client) + return + end + + -- Check cooldown + local now = timer.getTime() + local timeSinceLast = now - (playerData.lastIlluminationTime or 0) + if timeSinceLast < PILOT_INTUITION_CONFIG.illuminationCooldown then + local remaining = math.ceil(PILOT_INTUITION_CONFIG.illuminationCooldown - timeSinceLast) + MESSAGE:New("Illumination not ready. Wait " .. remaining .. " seconds.", 5):ToClient(client) + return + end + + -- Drop illumination flare at player position with altitude offset + local playerPos = playerUnit:GetCoordinate() + if not playerPos then + env.info("PilotIntuition: ERROR - Could not get player coordinate") + MESSAGE:New("Cannot determine position for illumination drop.", 5):ToClient(client) + return + end + PILog(LOG_INFO, "PilotIntuition: Player position: " .. playerPos:ToStringLLDMS()) + + -- Get altitude from unit directly (more reliable than coordinate) + local currentAlt = playerUnit:GetAltitude() + if not currentAlt or type(currentAlt) ~= "number" then + env.info("PilotIntuition: ERROR - Could not get valid altitude from unit") + MESSAGE:New("Cannot determine altitude for illumination drop.", 5):ToClient(client) + return + end + PILog(LOG_INFO, "PilotIntuition: Current altitude: " .. currentAlt .. "m") + + -- Create new coordinate at higher altitude + local illuAlt = currentAlt + PILOT_INTUITION_CONFIG.illuminationAltitude + local illuPos = COORDINATE:NewFromVec3(playerPos:GetVec3()) + illuPos = illuPos:SetAltitude(illuAlt) + PILog(LOG_INFO, "PilotIntuition: Illumination altitude: " .. illuAlt .. "m") + + -- Try to drop illumination bomb + PILog(LOG_INFO, "PilotIntuition: Attempting to drop illumination bomb") + illuPos:IlluminationBomb() + PILog(LOG_INFO, "PilotIntuition: IlluminationBomb() called successfully") + + -- Decrement flare count + playerData.illuminationFlares = playerData.illuminationFlares - 1 + playerData.lastIlluminationTime = now + + MESSAGE:New("Illumination flare dropped at your position. (" .. playerData.illuminationFlares .. " remaining)", 10):ToClient(client) + PILog(LOG_INFO, "PilotIntuition: Player " .. playerKey .. " dropped illumination at own position. " .. playerData.illuminationFlares .. " flares remaining") + + -- Rebuild menu to update flare count display + self:RebuildPlayerMenu(playerUnit) +end + +function PilotIntuition:MenuDropIlluminationOnTarget(playerUnit, index) + env.info("====== PilotIntuition: MenuDropIlluminationOnTarget CALLED ======") + env.info("PilotIntuition: playerUnit = " .. tostring(playerUnit)) + env.info("PilotIntuition: index = " .. tostring(index)) + if playerUnit then + env.info("PilotIntuition: playerUnit:GetName() = " .. tostring(playerUnit:GetName())) + end + if not playerUnit or not playerUnit:IsAlive() then + env.info("PilotIntuition: ERROR - No playerUnit or unit not alive") + return + end + + local playerKey = self:GetPlayerDataKey(playerUnit) + env.info("PilotIntuition: playerKey = " .. tostring(playerKey)) + + if not playerKey or not self.players[playerKey] then + env.info("PilotIntuition: ERROR - Player data not found for: " .. tostring(playerKey)) + return + end + + local playerData = self.players[playerKey] + local client = playerUnit:GetClient() + if not client then + env.info("PilotIntuition: ERROR - Could not get client") + return + end + + -- Check if player has flares remaining + if (playerData.illuminationFlares or 0) <= 0 then + MESSAGE:New("No illumination flares remaining. Land at a friendly airbase to rearm.", 10):ToClient(client) + return + end + + -- Check cooldown + local now = timer.getTime() + local timeSinceLast = now - (playerData.lastIlluminationTime or 0) + if timeSinceLast < PILOT_INTUITION_CONFIG.illuminationCooldown then + local remaining = math.ceil(PILOT_INTUITION_CONFIG.illuminationCooldown - timeSinceLast) + MESSAGE:New("Illumination not ready. Wait " .. remaining .. " seconds.", 5):ToClient(client) + return + end + + -- Get scanned target + local scanned = playerData.scannedGroundTargets or {} + local group = scanned[index] + if not group or not group:IsAlive() then + MESSAGE:New("Target " .. index .. " not available.", 10):ToClient(client) + return + end + + -- Drop illumination flare over target with altitude offset + local targetPos = group:GetCoordinate() + if not targetPos then + env.info("PilotIntuition: ERROR - Could not get target coordinate") + MESSAGE:New("Cannot determine target position for illumination drop.", 5):ToClient(client) + return + end + PILog(LOG_INFO, "PilotIntuition: Target position: " .. targetPos:ToStringLLDMS()) + + -- Get altitude from ground level (ground units are at ground level) + -- Use a safe default altitude for ground targets + local targetAlt = 0 + local firstUnit = group:GetUnit(1) + if firstUnit and firstUnit:IsAlive() then + local unitAlt = firstUnit:GetAltitude() + if unitAlt and type(unitAlt) == "number" then + targetAlt = unitAlt + end + end + PILog(LOG_INFO, "PilotIntuition: Target altitude: " .. targetAlt .. "m") + + -- Create new coordinate at higher altitude + local illuAlt = targetAlt + PILOT_INTUITION_CONFIG.illuminationAltitude + local illuPos = COORDINATE:NewFromVec3(targetPos:GetVec3()) + illuPos = illuPos:SetAltitude(illuAlt) + PILog(LOG_INFO, "PilotIntuition: Illumination altitude: " .. illuAlt .. "m") + + -- Try to drop illumination bomb + PILog(LOG_INFO, "PilotIntuition: Attempting to drop illumination bomb on target") + illuPos:IlluminationBomb() + PILog(LOG_INFO, "PilotIntuition: IlluminationBomb() called successfully") + + -- Decrement flare count + playerData.illuminationFlares = playerData.illuminationFlares - 1 + playerData.lastIlluminationTime = now + + local playerCoord = playerUnit:GetCoordinate() + if not playerCoord then + PILog(LOG_ERROR, "PilotIntuition: Could not get player coordinate for illumination drop") + MESSAGE:New("Error: Could not determine your position.", 10):ToClient(client) + return + end + + local bearing = playerCoord:GetAngleDegrees(playerCoord:GetDirectionVec3(targetPos)) + local rawDistance = playerCoord:Get2DDistance(targetPos) + local distance, unit = self:FormatDistance(rawDistance, playerKey) + MESSAGE:New(string.format("Illumination flare dropped on Target %d (%.0f°, %.1f%s). (%d remaining)", index, bearing, distance, unit, playerData.illuminationFlares), 10):ToClient(client) + PILog(LOG_INFO, "PilotIntuition: Player " .. playerKey .. " dropped illumination on target " .. index .. ". " .. playerData.illuminationFlares .. " flares remaining") + + -- Rebuild menu to update flare count display + self:RebuildPlayerMenu(playerUnit) +end + +function PilotIntuition:MenuSetActiveMessaging(onoff) + env.info("PilotIntuition: MenuSetActiveMessaging called with " .. tostring(onoff)) + PILOT_INTUITION_CONFIG.activeMessaging = onoff + if onoff then + local msg = self:GetRandomMessage("activeMessagingToggle", {"enabled"}) + env.info("PilotIntuition: Active messaging message: " .. msg) + self:BroadcastMessageToAll(msg) + if self.summaryScheduler then + self.summaryScheduler:Stop() + self.summaryScheduler = nil + end + else + local msg = self:GetRandomMessage("activeMessagingToggle", {"disabled. Use summaries for updates."}) + env.info("PilotIntuition: Active messaging message: " .. msg) + self:BroadcastMessageToAll(msg) + if PILOT_INTUITION_CONFIG.summaryInterval > 0 then + self.summaryScheduler = SCHEDULER:New(nil, self.SendScheduledSummaries, {self}, 1, PILOT_INTUITION_CONFIG.summaryInterval) + end + end +end + +function PilotIntuition:MenuSetAirScanning(onoff) + env.info("PilotIntuition: MenuSetAirScanning called with " .. tostring(onoff)) + PILOT_INTUITION_CONFIG.enableAirScanning = onoff + local status = onoff and "enabled" or "disabled" + local msg = self:GetRandomMessage("airScanningToggle", {status}) + env.info("PilotIntuition: Air scanning message: " .. msg) + self:BroadcastMessageToAll(msg) +end + +function PilotIntuition:MenuSetGroundScanning(onoff) + env.info("PilotIntuition: MenuSetGroundScanning called with " .. tostring(onoff)) + PILOT_INTUITION_CONFIG.enableGroundScanning = onoff + local status = onoff and "enabled" or "disabled" + local msg = self:GetRandomMessage("groundScanningToggle", {status}) + env.info("PilotIntuition: Ground scanning message: " .. msg) + self:BroadcastMessageToAll(msg) +end + +function PilotIntuition:MenuSendPlayerSummary(playerUnit, detailLevel) + self:SendPlayerSummary(playerUnit, detailLevel) +end + +function PilotIntuition:BroadcastMessageToAll(text) + env.info("PilotIntuition: Broadcasting message: " .. tostring(text)) + MESSAGE:New(text, 10):ToAll() -- Added duration 10 seconds + env.info("PilotIntuition: Broadcasted to all with duration 10") +end + +function PilotIntuition:GetPlayerSummary(playerName, detailLevel, playerUnit) + local data = self.players[playerName] + if not data then return nil end + + -- Get player position for bearing calculations + local playerPos = nil + local playerHeading = 0 + if playerUnit and playerUnit:IsAlive() then + playerPos = playerUnit:GetCoordinate() + playerHeading = math.deg(playerUnit:GetHeading()) + end + + -- Collect air threats with details + local airThreats = {} + for id, t in pairs(data.trackedAirTargets or {}) do + if t.unit and t.unit:IsAlive() then + local distance = t.lastRange or 0 + local bearing = 0 + local relativeBearing = 0 + local threat = "UNKNOWN" + + if playerPos then + bearing = playerPos:GetAngleDegrees(playerPos:GetDirectionVec3(t.unit:GetCoordinate())) + relativeBearing = (bearing - playerHeading + 360) % 360 + + -- Determine threat level + if distance < PILOT_INTUITION_CONFIG.threatHotRange then + threat = "HOT" + elseif distance < PILOT_INTUITION_CONFIG.threatColdRange then + threat = "COLD" + else + threat = "FAR" + end + end + + table.insert(airThreats, { + name = t.unit:GetTypeName() or "Unknown", + bearing = bearing, + distance = distance, + altitude = t.unit:GetAltitude() or 0, + threat = threat + }) + end + end + + -- Sort air threats by distance (closest first) + table.sort(airThreats, function(a, b) return a.distance < b.distance end) + + -- Collect ground threats with details + local groundThreats = {} + for id, _ in pairs(data.trackedGroundTargets or {}) do + -- Look up actual ground group from global table + local groundData = self.trackedGroundTargets[id] + if groundData and groundData.group and groundData.group:IsAlive() then + local g = groundData.group + local gPos = g:GetCoordinate() + local distance = 0 + local bearing = 0 + local category = "Ground" + + if playerPos and gPos then + distance = playerPos:Get2DDistance(gPos) + bearing = playerPos:GetAngleDegrees(playerPos:GetDirectionVec3(gPos)) + end + + -- Classify ground unit + local firstUnit = g:GetUnit(1) + if firstUnit then + local unitType = firstUnit:GetTypeName() or "" + category = self:ClassifyGroundUnit(unitType) + end + + table.insert(groundThreats, { + name = g:GetName() or "Unknown", + category = category, + bearing = bearing, + distance = distance + }) + end + end + + -- Sort ground threats by distance + table.sort(groundThreats, function(a, b) return a.distance < b.distance end) + + -- Build summary based on detail level + if detailLevel == "brief" then + -- Brief: Quick tactical overview + local parts = {} + + if #airThreats > 0 then + local closest = airThreats[1] + local distance, unit = self:FormatDistance(closest.distance, playerName) + table.insert(parts, string.format("%d bandit%s (closest: %s @ %.1f%s %s)", + #airThreats, + #airThreats > 1 and "s" or "", + closest.name, + distance, + unit, + closest.threat)) + end + + if #groundThreats > 0 then + table.insert(parts, string.format("%d ground group%s", #groundThreats, #groundThreats > 1 and "s" or "")) + end + + -- Formation status + local wingmen = data.cachedWingmen or 0 + if wingmen > 0 then + local mult = (wingmen > 0) and (2 * wingmen) or 1 + if mult > PILOT_INTUITION_CONFIG.maxMultiplier then mult = PILOT_INTUITION_CONFIG.maxMultiplier end + local envMult = self:GetDetectionMultiplier() + local airRange, airUnit = self:FormatDistance(PILOT_INTUITION_CONFIG.airDetectionRange * mult * envMult, playerName) + local groundRange, groundUnit = self:FormatDistance(PILOT_INTUITION_CONFIG.groundDetectionRange * mult * envMult, playerName) + table.insert(parts, string.format("Formation: %d wingmen (detect: %.0f%s air/%.0f%s gnd)", wingmen, airRange, airUnit, groundRange, groundUnit)) + else + table.insert(parts, "Solo (reduced detection)") + end + + if #parts == 0 then + return nil + end + + return table.concat(parts, " | ") + + else + -- Detailed: Full tactical situation report + local lines = {} + table.insert(lines, "=== TACTICAL SITREP ===") + + -- Air threats section + if #airThreats > 0 then + table.insert(lines, "\nAIR THREATS:") + for i, threat in ipairs(airThreats) do + local angels = math.floor(threat.altitude * 3.28084 / 1000) -- Convert to thousands of feet + local distance, unit = self:FormatDistance(threat.distance, playerName) + table.insert(lines, string.format(" %d. %s @ %03d°, %.1f%s, angels %d (%s)", + i, threat.name, math.floor(threat.bearing), distance, unit, angels, threat.threat)) + end + else + table.insert(lines, "\nAIR THREATS: None") + end + + -- Ground threats section + if #groundThreats > 0 then + table.insert(lines, "\nGROUND THREATS:") + for i, threat in ipairs(groundThreats) do + local distance, unit = self:FormatDistance(threat.distance, playerName) + table.insert(lines, string.format(" %d. %s @ %03d°, %.1f%s", + i, threat.category, math.floor(threat.bearing), distance, unit)) + end + else + table.insert(lines, "\nGROUND THREATS: None") + end + + -- Formation and detection status + local wingmen = data.cachedWingmen or 0 + local mult = (wingmen > 0) and (2 * wingmen) or 1 + if mult > PILOT_INTUITION_CONFIG.maxMultiplier then mult = PILOT_INTUITION_CONFIG.maxMultiplier end + local envMult = self:GetDetectionMultiplier() + local airRange, airUnit = self:FormatDistance(PILOT_INTUITION_CONFIG.airDetectionRange * mult * envMult, playerName) + local groundRange, groundUnit = self:FormatDistance(PILOT_INTUITION_CONFIG.groundDetectionRange * mult * envMult, playerName) + + table.insert(lines, "\nFORMATION:") + if wingmen > 0 then + table.insert(lines, string.format(" Wingmen: %d (multiplier: x%d)", wingmen, mult)) + else + table.insert(lines, " Status: Solo flight") + end + table.insert(lines, string.format(" Detection: %.0f%s air, %.0f%s ground", airRange, airUnit, groundRange, groundUnit)) + + return table.concat(lines, "\n") + end +end + +function PilotIntuition:SendPlayerSummary(playerUnit, detailLevel) + env.info("PilotIntuition: SendPlayerSummary called with detailLevel: " .. tostring(detailLevel)) + if not playerUnit then + env.info("PilotIntuition: No playerUnit provided") + return + end + local playerKey = self:GetPlayerDataKey(playerUnit) + env.info("PilotIntuition: Player key from unit: " .. tostring(playerKey)) + local client = playerUnit:GetClient() + if not client then + env.info("PilotIntuition: Could not get client for player") + return + end + if not playerKey or not self.players[playerKey] then + env.info("PilotIntuition: Player data not found for: " .. tostring(playerKey)) + env.info("PilotIntuition: Available players: " .. table.concat(self:GetPlayerKeys(), ", ")) + return + end + local now = timer.getTime() + local data = self.players[playerKey] + if data.lastSummaryTime and (now - data.lastSummaryTime) < (PILOT_INTUITION_CONFIG.summaryCooldown or 2) then + MESSAGE:New(self:GetRandomMessage("summaryCooldown"), 5):ToClient(client) + return + end + local summary = self:GetPlayerSummary(playerKey, detailLevel, playerUnit) + env.info("PilotIntuition: Summary generated: " .. tostring(summary)) + if summary and summary ~= "" then + -- Use longer duration for detailed reports + local duration = (detailLevel == "detailed") and 30 or 15 + MESSAGE:New(summary, duration):ToClient(client) + data.lastSummaryTime = now + else + MESSAGE:New(self:GetRandomMessage("noThreats"), 10):ToClient(client) + end +end + +function PilotIntuition:GetPlayerKeys() + local keys = {} + for k, _ in pairs(self.players) do + table.insert(keys, k) + end + return keys +end + +function PilotIntuition:SendScheduledSummaries() + if not PILOT_INTUITION_CONFIG.activeMessaging then + local clients = SET_CLIENT:New():FilterActive():FilterOnce() + clients:ForEachClient(function(client) + if client and type(client.GetUnit) == "function" then + local unit = client:GetUnit() + if unit and unit:IsAlive() then + self:SendPlayerSummary(unit, "brief") + end + end + end) + end +end + +function PilotIntuition:StartScheduler() + -- Schedule periodic scans + SCHEDULER:New(nil, self.ScanTargets, {self}, 1, PILOT_INTUITION_CONFIG.scanInterval) +end + +-- (old ScanTargets removed; using the optimized ScanTargets above) + +function PilotIntuition:ScanAirTargetsForPlayer(playerUnit, playerData, client, activeClients, enemyAirUnits) + local playerKey = self:GetPlayerDataKey(playerUnit) + local playerPos = playerUnit:GetCoordinate() + local playerCoalition = playerUnit:GetCoalition() + local enemyCoalition = (playerCoalition == coalition.side.BLUE) and coalition.side.RED or coalition.side.BLUE + + -- Use cached wingmen count + local wingmen = playerData.cachedWingmen + local multiplier = (wingmen > 0) and (2 * wingmen) or 1 + -- Clamp multiplier to configured maximum + if multiplier > PILOT_INTUITION_CONFIG.maxMultiplier then + multiplier = PILOT_INTUITION_CONFIG.maxMultiplier + end + local envMult = self:GetDetectionMultiplier() + local detectionRange = PILOT_INTUITION_CONFIG.airDetectionRange * multiplier * envMult + + PILog(LOG_DEBUG, string.format("PilotIntuition: Scanning %d enemy air units within %.0fm for player %s", + #enemyAirUnits, detectionRange, playerKey)) + + -- Notify formation changes (suppress during active combat if configured) + local previousWingmen = playerData.previousWingmen or 0 + local now = timer.getTime() + PILog(LOG_DEBUG, "PilotIntuition: Formation check - previous: " .. previousWingmen .. ", current: " .. wingmen .. ", cached: " .. playerData.cachedWingmen) + + -- Check if player is in active combat (has engaged bandits within hot range recently) + local inCombat = false + if PILOT_INTUITION_CONFIG.suppressFormationInCombat then + for _, data in pairs(playerData.trackedAirTargets) do + if data.engaged and (now - (data.lastEngagedTime or 0)) < 30 then + inCombat = true + break + end + end + end + + if not inCombat and (now - playerData.lastFormationMessageTime) >= PILOT_INTUITION_CONFIG.formationMessageCooldown then + local newWingmen = playerData.cachedWingmen + local newMultiplier = (newWingmen > 0) and (2 * newWingmen) or 1 + -- Clamp multiplier to configured maximum + if newMultiplier > PILOT_INTUITION_CONFIG.maxMultiplier then + newMultiplier = PILOT_INTUITION_CONFIG.maxMultiplier + end + local newAirRangeMeters = PILOT_INTUITION_CONFIG.airDetectionRange * newMultiplier * envMult + local newGroundRangeMeters = PILOT_INTUITION_CONFIG.groundDetectionRange * newMultiplier * envMult + + -- Convert to player's preferred units + local newAirRange, airUnit = self:FormatDistance(newAirRangeMeters, playerKey) + local newGroundRange, groundUnit = self:FormatDistance(newGroundRangeMeters, playerKey) + + PILog(LOG_DEBUG, "PilotIntuition: Formation change check - new: " .. newWingmen .. ", prev: " .. previousWingmen .. ", activeMessaging: " .. tostring(PILOT_INTUITION_CONFIG.activeMessaging)) + + if newWingmen > previousWingmen then + PILog(LOG_INFO, "PilotIntuition: Formation joined - sending message") + if PILOT_INTUITION_CONFIG.activeMessaging then + MESSAGE:New(self:GetRandomMessage("formationJoin", {"wingman", newAirRange, airUnit, newGroundRange, groundUnit}), 10):ToClient(client) + end + playerData.lastFormationMessageTime = now + elseif newWingmen < previousWingmen then + PILog(LOG_INFO, "PilotIntuition: Formation left - sending message") + if PILOT_INTUITION_CONFIG.activeMessaging then + MESSAGE:New(self:GetRandomMessage("formationLeave", {"wingman", newAirRange, airUnit, newGroundRange, groundUnit}), 10):ToClient(client) + end + playerData.lastFormationMessageTime = now + end + else + if inCombat then + PILog(LOG_DEBUG, "PilotIntuition: Formation change suppressed - player in combat") + else + PILog(LOG_DEBUG, "PilotIntuition: Formation change message on cooldown") + end + end + playerData.previousWingmen = playerData.cachedWingmen + + -- Formation integrity warning (only if previously had wingmen) + if previousWingmen >= PILOT_INTUITION_CONFIG.minFormationWingmen and wingmen < PILOT_INTUITION_CONFIG.minFormationWingmen then + if not playerData.formationWarned and (now - playerData.lastFormationMessageTime) >= PILOT_INTUITION_CONFIG.formationMessageCooldown then + if PILOT_INTUITION_CONFIG.activeMessaging then + MESSAGE:New(self:GetRandomMessage("formationIntegrityLow"), 10):ToClient(client) + end + playerData.formationWarned = true + playerData.lastFormationMessageTime = now + end + elseif wingmen >= PILOT_INTUITION_CONFIG.minFormationWingmen then + playerData.formationWarned = false + end + + -- Prune dead or out-of-range air targets + for id, data in pairs(playerData.trackedAirTargets) do + if not data.unit:IsAlive() or playerPos:Get2DDistance(data.unit:GetCoordinate()) > detectionRange then + -- Explicitly nil out sub-fields before removing to help GC + data.unit = nil + data.banditName = nil + playerData.trackedAirTargets[id] = nil + end + end + + local banditCount = 0 + local closestUnit = nil + local minDistance = math.huge + -- Reuse existing threateningBandits table to reduce GC pressure + local threateningBandits = playerData.threateningBandits or {} + -- Clear the table (faster than creating new one) + for i = #threateningBandits, 1, -1 do + threateningBandits[i] = nil + end + + for _, unit in ipairs(enemyAirUnits) do + PILog(LOG_DEBUG, string.format("PilotIntuition: Checking enemy unit: %s, IsAlive: %s", + unit and unit:GetName() or "nil", tostring(unit and unit:IsAlive()))) + if unit and unit:IsAlive() then + local unitCoord = unit:GetCoordinate() + if not unitCoord then + PILog(LOG_DEBUG, "PilotIntuition: Could not get coordinate for " .. unit:GetName()) + else + local distance = playerPos:Get2DDistance(unitCoord) + PILog(LOG_DEBUG, string.format("PilotIntuition: Unit %s distance: %.0fm (range: %.0fm)", + unit:GetName(), distance, detectionRange)) + + -- Check if within detection range AND has line of sight + if distance <= detectionRange then + -- Use coordinate-based LOS check from player to enemy + local hasLOS = playerPos:IsLOS(unitCoord) + PILog(LOG_DEBUG, string.format("PilotIntuition: Unit %s in range, LOS check: %s", + unit:GetName(), tostring(hasLOS))) + + if hasLOS then + banditCount = banditCount + 1 + PILog(LOG_DEBUG, string.format("PilotIntuition: Enemy detected with LOS: %s at %.0fm", unit:GetName(), distance)) + if distance < minDistance then + minDistance = distance + closestUnit = unit + end + + local targetID = unit:GetName() + local now = timer.getTime() + + -- Calculate bearing from player to bandit + local bearing = playerPos:HeadingTo(unitCoord) + local playerHeading = playerUnit:GetHeading() -- Already in degrees + + -- Calculate relative bearing (clock position) + local relativeBearing = (bearing - playerHeading + 360) % 360 + + -- Calculate bandit's aspect angle (is he nose-on or tail-on to us?) + local banditHeading = unit:GetHeading() -- Already in degrees + local banditToBearing = (bearing + 180) % 360 -- Reverse bearing (from bandit to player) + local aspectAngle = math.abs(banditToBearing - banditHeading) + if aspectAngle > 180 then aspectAngle = 360 - aspectAngle end -- Normalize to 0-180 + + -- Debug logging for first contact + if distance < 20000 and not playerData.trackedAirTargets[targetID] then + PILog(LOG_DEBUG, string.format("PilotIntuition: Initial contact %s - playerHdg:%.0f° bearing:%.0f° relBrg:%.0f° banditHdg:%.0f° aspect:%.0f°", + targetID, playerHeading, bearing, relativeBearing, banditHeading, aspectAngle)) + end + + if not playerData.trackedAirTargets[targetID] then + local banditName = unit:GetPlayerName() or unit:GetName() + playerData.trackedAirTargets[targetID] = { unit = unit, engaged = false, lastRange = distance, lastTime = now, wasHot = false, lastRelativeBearing = relativeBearing, lastEngagedTime = 0, banditName = banditName } + + -- Safety: Limit tracked targets to prevent memory bloat in long missions (keep 50 most recent) + local trackedCount = 0 + for _ in pairs(playerData.trackedAirTargets) do trackedCount = trackedCount + 1 end + if trackedCount > 50 then + -- Remove oldest non-engaged target + local oldestID, oldestTime = nil, math.huge + for tid, tdata in pairs(playerData.trackedAirTargets) do + if not tdata.engaged and tdata.lastTime < oldestTime then + oldestTime = tdata.lastTime + oldestID = tid + end + end + if oldestID then + playerData.trackedAirTargets[oldestID].unit = nil + playerData.trackedAirTargets[oldestID].banditName = nil + playerData.trackedAirTargets[oldestID] = nil + end + end + else + playerData.trackedAirTargets[targetID].lastRange = playerData.trackedAirTargets[targetID].lastRange or distance + playerData.trackedAirTargets[targetID].lastTime = playerData.trackedAirTargets[targetID].lastTime or now + playerData.trackedAirTargets[targetID].lastRelativeBearing = playerData.trackedAirTargets[targetID].lastRelativeBearing or relativeBearing + end + local data = playerData.trackedAirTargets[targetID] + local closing = distance < data.lastRange + local wasHot = data.wasHot + if distance <= PILOT_INTUITION_CONFIG.threatHotRange then + data.wasHot = true + end + data.lastRange = distance + data.lastTime = now + local lastRelativeBearing = data.lastRelativeBearing + data.lastRelativeBearing = relativeBearing + + -- Reset engaged status after 2 minutes of no engagement or if bandit is out of range + if data.engaged and ((now - data.lastEngagedTime) > 120 or distance > PILOT_INTUITION_CONFIG.airDetectionRange) then + data.engaged = false + -- Only report "dogfight concluded" if the bandit was actually hot at some point (actual engagement) + if data.wasHot and (now - playerData.lastConclusionTime) >= (PILOT_INTUITION_CONFIG.messageCooldown * playerData.frequencyMultiplier) then + if PILOT_INTUITION_CONFIG.activeMessaging then + local banditName = data.banditName or "bandit" + local msg = string.format("%s escaped!", banditName) + MESSAGE:New(msg, 10):ToClient(client) + end + playerData.lastConclusionTime = now + end + end + + if not data.engaged then + -- Calculate altitude information + local playerAlt = playerUnit:GetAltitude() + local banditAlt = unit:GetAltitude() + local altDelta = banditAlt - playerAlt + + -- Check for dogfight situations + local onTail = relativeBearing > 150 and relativeBearing < 210 + local headOn = relativeBearing < 30 or relativeBearing > 330 + local beam = (relativeBearing > 60 and relativeBearing < 120) or (relativeBearing > 240 and relativeBearing < 300) + local overshoot = distance < PILOT_INTUITION_CONFIG.mergeRange and lastRelativeBearing < 90 and relativeBearing > 270 and not closing + + -- High/Low merge detection + local highMerge = distance < PILOT_INTUITION_CONFIG.mergeRange and closing and altDelta > PILOT_INTUITION_CONFIG.highMergeAltitude + local lowMerge = distance < PILOT_INTUITION_CONFIG.mergeRange and closing and altDelta < -PILOT_INTUITION_CONFIG.lowMergeAltitude + local coAltMerge = distance < PILOT_INTUITION_CONFIG.mergeRange and closing and math.abs(altDelta) < 200 + + -- Separation after being hot + local separating = wasHot and distance > PILOT_INTUITION_CONFIG.separatingRange and not closing + + -- Build threat descriptor for ALL detected bandits + local threatType = nil + local threatDetail = nil + + -- Determine threat level based on aspect angle AND distance + -- Hot = nose-on (0-45°), Cold = tail aspect (135-180°), Flanking/Beam = side aspect + local threatLevel = "distant" + if distance <= PILOT_INTUITION_CONFIG.threatHotRange then + threatLevel = "hot" + elseif distance <= PILOT_INTUITION_CONFIG.threatColdRange then + -- Use aspect angle to determine if truly hot or cold + if aspectAngle <= 45 then + threatLevel = "hot" -- Nose-on, coming at us + elseif aspectAngle >= 135 then + threatLevel = "cold" -- Tail aspect, running away + else + threatLevel = "flanking" -- Side aspect + end + else + threatLevel = "distant" + end + + -- Check for specific tactical situations (high priority) + if highMerge then + threatType = "high merge" + threatDetail = string.format("%.0fm above", altDelta) + elseif lowMerge then + threatType = "low merge" + threatDetail = string.format("%.0fm below", math.abs(altDelta)) + elseif coAltMerge then + threatType = "co-alt merge" + elseif onTail and distance < PILOT_INTUITION_CONFIG.tailWarningRange then + if altDelta > 200 then + threatType = "tail high" + elseif altDelta < -200 then + threatType = "tail low" + else + threatType = "tail" + end + elseif headOn and distance < PILOT_INTUITION_CONFIG.headOnRange then + threatType = "head-on" + if math.abs(altDelta) > 200 then + threatDetail = string.format("alt %.0f", altDelta) + end + elseif beam and distance < PILOT_INTUITION_CONFIG.beamRange then + threatType = "beam" + local side = (relativeBearing > 180) and "left" or "right" + threatDetail = side + elseif separating then + threatType = "separating" + elseif overshoot then + threatType = "overshot" + else + -- Generic aspect-based description for distant contacts + local clockPos = math.floor((relativeBearing + 15) / 30) + 1 + if clockPos > 12 then clockPos = clockPos - 12 end + threatType = threatLevel + local distValue, distUnit = self:FormatDistance(distance, playerKey) + threatDetail = string.format("%d o'clock, %.1f%s", clockPos, distValue, distUnit) + if closing then + threatDetail = threatDetail .. ", closing" + end + end + + -- Add ALL detected bandits to the list for multi-bandit reporting + local banditName = unit:GetPlayerName() or unit:GetName() + table.insert(threateningBandits, { + unit = unit, + distance = distance, + threatType = threatType, + threatDetail = threatDetail, + relativeBearing = relativeBearing, + threatLevel = threatLevel, + banditName = banditName + }) + end + + -- Dogfight assist features (if enabled) + if playerData.dogfightAssist and data.engaged then + -- Calculate effective cooldown based on combat intensity + local baseCooldown = PILOT_INTUITION_CONFIG.dogfightMessageCooldown + local effectiveCooldown = baseCooldown + + -- If in high-intensity combat (many bandits), increase cooldown to reduce spam + local engagedBandits = 0 + for _, targetData in pairs(playerData.trackedAirTargets) do + if targetData.engaged then + engagedBandits = engagedBandits + 1 + end + end + + if engagedBandits >= PILOT_INTUITION_CONFIG.combatIntensityThreshold then + effectiveCooldown = baseCooldown * PILOT_INTUITION_CONFIG.combatIntensityCooldownMultiplier + PILog(LOG_DEBUG, string.format("PilotIntuition: High-intensity combat (%d bandits) - dogfight cooldown increased to %.1fs", engagedBandits, effectiveCooldown)) + end + + self:ProvideDogfightAssist(playerUnit, unit, distance, relativeBearing, lastRelativeBearing, playerData, client, closing, effectiveCooldown) + end + end -- End if hasLOS + else + PILog(LOG_TRACE, string.format("PilotIntuition: Unit %s out of range: %.0fm > %.0fm", + unit:GetName(), distance, detectionRange)) + end -- End if distance <= detectionRange + end -- End if unitCoord + end -- End if unit and unit:IsAlive() + end -- End for _, unit in ipairs(enemyAirUnits) + + -- Report multiple bandits situation with tactical picture + PILog(LOG_DEBUG, string.format("PilotIntuition: Threatening bandits count: %d", #threateningBandits)) + + -- COMBAT FOCUS: Filter out distant threats when in close combat + local inCloseCombat = false + local combatFocusRange = PILOT_INTUITION_CONFIG.threatHotRange * 2 -- 2km threshold for "close combat" + for _, threat in ipairs(threateningBandits) do + if threat.distance <= combatFocusRange and threat.threatLevel == "hot" then + inCloseCombat = true + break + end + end + + -- If in close combat, filter to only show immediate threats + local filteredThreats = threateningBandits + if inCloseCombat then + filteredThreats = {} + for _, threat in ipairs(threateningBandits) do + -- Only report threats within combat focus range OR threats that are hot/flanking + if threat.distance <= combatFocusRange or threat.threatLevel == "hot" or threat.threatLevel == "flanking" then + table.insert(filteredThreats, threat) + end + end + PILog(LOG_DEBUG, string.format("PilotIntuition: COMBAT FOCUS - Filtered %d distant threats, showing %d immediate threats", + #threateningBandits - #filteredThreats, #filteredThreats)) + end + + if #filteredThreats > 0 then + for i, threat in ipairs(filteredThreats) do + PILog(LOG_DEBUG, string.format(" Threat %d: %s at %.0fm, type: %s", i, threat.unit:GetName(), threat.distance, threat.threatType)) + end + local now = timer.getTime() + if (now - playerData.lastDogfightTime) >= (PILOT_INTUITION_CONFIG.messageCooldown * playerData.frequencyMultiplier) then + local message = "" + if #filteredThreats == 1 then + -- Single bandit - detailed callout + local threat = filteredThreats[1] + local banditName = threat.banditName or "bandit" + if threat.threatDetail then + message = string.format("%s - %s! %s", banditName, threat.threatType:gsub("^%l", string.upper), threat.threatDetail) + else + message = string.format("%s - %s!", banditName, threat.threatType:gsub("^%l", string.upper)) + end + else + -- Multiple bandits - build tactical picture, showing only most threatening + -- Sort by threat priority: hot > flanking > cold > distant, then by distance + table.sort(filteredThreats, function(a, b) + local priorityOrder = {hot = 1, flanking = 2, cold = 3, distant = 4} + local aPriority = priorityOrder[a.threatLevel] or 5 + local bPriority = priorityOrder[b.threatLevel] or 5 + if aPriority ~= bPriority then + return aPriority < bPriority + else + return a.distance < b.distance + end + end) + + local maxDisplay = PILOT_INTUITION_CONFIG.maxThreatDisplay + local displayCount = math.min(maxDisplay, #filteredThreats) + local totalCount = #filteredThreats + + message = string.format("Multiple bandits - %d contacts", totalCount) + if displayCount < totalCount then + message = message .. string.format(" (showing %d most threatening)", displayCount) + end + message = message .. ": " + + local threats = {} + for i = 1, displayCount do + local threat = filteredThreats[i] + local banditName = threat.banditName or "unknown" + local desc = banditName .. " - " .. threat.threatType + if threat.threatDetail then + desc = desc .. " (" .. threat.threatDetail .. ")" + end + table.insert(threats, desc) + end + message = message .. table.concat(threats, ", ") + end + + if PILOT_INTUITION_CONFIG.activeMessaging then + MESSAGE:New(message, 10):ToClient(client) + end + playerData.lastDogfightTime = now + + -- Mark only HOT bandits as engaged (within hot range or actively threatening) + for _, threat in ipairs(threateningBandits) do + local data = playerData.trackedAirTargets[threat.unit:GetName()] + if data and threat.threatLevel == "hot" then + data.engaged = true + data.lastEngagedTime = now + end + end + end + end + + -- Report the closest unengaged bandit (if no threats) + if #threateningBandits == 0 and closestUnit then + local data = playerData.trackedAirTargets[closestUnit:GetName()] + if data and not data.engaged then + local playerKey = self:GetPlayerDataKey(playerUnit) + self:ReportAirTarget(closestUnit, playerPos, playerData, client, playerKey) + end + end + + -- Check for multiple bandits (with 5-minute cooldown to reduce spam) + if banditCount > 1 then + local now = timer.getTime() + if (now - (playerData.lastMultipleBanditsWarningTime or 0)) >= PILOT_INTUITION_CONFIG.multipleBanditsWarningCooldown then + self:ReportDogfight(nil, playerPos, playerData, client, "Multiple bandits in vicinity!") + playerData.lastMultipleBanditsWarningTime = now + end + end +end + +function PilotIntuition:ReportAirTarget(unit, playerPos, playerData, client, playerKey) + local now = timer.getTime() + if now - playerData.lastMessageTime < (PILOT_INTUITION_CONFIG.messageCooldown * playerData.frequencyMultiplier) then return end + if not PILOT_INTUITION_CONFIG.activeMessaging then return end + + local unitCoord = unit:GetCoordinate() + if not unitCoord then + PILog(LOG_DEBUG, "PilotIntuition: Could not get unit coordinate for air target report") + return + end + + local bearing = playerPos:GetAngleDegrees(playerPos:GetDirectionVec3(unitCoord)) + local rangeMeters = playerPos:Get2DDistance(unitCoord) + local range, distUnit = self:FormatDistance(rangeMeters, playerKey) + -- Convert altitude to angels (thousands of feet) + local altMeters = unit:GetAltitude() + local altFeet = altMeters * 3.28084 + local angels = math.floor(altFeet / 1000) + local threat = "cold" + if rangeMeters <= PILOT_INTUITION_CONFIG.threatHotRange then + threat = "hot" + end + + local group = unit:GetGroup() + if not group then + PILog(LOG_DEBUG, "PilotIntuition: Could not get group for air target") + return + end + local groupSize = #group:GetUnits() + local sizeDesc = groupSize == 1 and "single" or (groupSize == 2 and "pair" or "flight of " .. groupSize) + + MESSAGE:New(self:GetRandomMessage("airTargetDetected", {threat, bearing, range, distUnit, angels, sizeDesc}), 10):ToClient(client) + playerData.lastMessageTime = now +end + +function PilotIntuition:ReportDogfight(unit, playerPos, playerData, client, message) + local now = timer.getTime() + if now - playerData.lastMessageTime < (PILOT_INTUITION_CONFIG.messageCooldown * playerData.frequencyMultiplier) then return end + if not PILOT_INTUITION_CONFIG.activeMessaging then return end + + MESSAGE:New(message, 10):ToClient(client) + playerData.lastMessageTime = now +end + +function PilotIntuition:ScanGroundTargetsForPlayer(playerUnit, client, activeClients, enemyGroundGroups, placeMarker) + if placeMarker == nil then placeMarker = true end + local playerPos = playerUnit:GetCoordinate() + local playerCoalition = playerUnit:GetCoalition() + local enemyCoalition = (playerCoalition == coalition.side.BLUE) and coalition.side.RED or coalition.side.BLUE + local unitName = playerUnit:GetName() + local playerData = self.players[unitName] + if not playerData then return end + + -- Use cached wingmen count + local wingmen = playerData.cachedWingmen + local multiplier = (wingmen > 0) and (2 * wingmen) or 1 + if multiplier > PILOT_INTUITION_CONFIG.maxMultiplier then + multiplier = PILOT_INTUITION_CONFIG.maxMultiplier + end + local envMult = self:GetDetectionMultiplier() + local detectionRange = PILOT_INTUITION_CONFIG.groundDetectionRange * multiplier * envMult + + -- Prune out-of-range ground targets for this player + for id, _ in pairs(playerData.trackedGroundTargets) do + local group = self.trackedGroundTargets[id] + if not group or not group.group:IsAlive() or playerPos:Get2DDistance(group.group:GetCoordinate()) > detectionRange then + playerData.trackedGroundTargets[id] = nil + end + end + + -- Find the closest unmarked target for this player (no sorting needed) + local closestGroup = nil + local minDistance = math.huge + for _, group in ipairs(enemyGroundGroups or {}) do + local distance = playerPos:Get2DDistance(group:GetCoordinate()) + if distance <= detectionRange then + local targetID = group:GetName() + if not self.trackedGroundTargets[targetID] then + self.trackedGroundTargets[targetID] = { group = group, marked = false } + end + if not playerData.trackedGroundTargets[targetID] and distance < minDistance then + closestGroup = group + minDistance = distance + end + end + end + + -- Mark and report the closest unmarked target + if closestGroup then + local targetID = closestGroup:GetName() + self:ReportGroundTarget(closestGroup, playerUnit, client, placeMarker) + self.trackedGroundTargets[targetID].marked = true + playerData.trackedGroundTargets[targetID] = true + end +end + +function PilotIntuition:CheckCloseFlyingForPlayer(playerUnit, playerData, client, activeClients) + if not PILOT_INTUITION_CONFIG.enableCloseFlyingCompliments then return end + local playerPos = playerUnit:GetCoordinate() + local playerCoalition = playerUnit:GetCoalition() + local now = timer.getTime() + local playerHeading = math.deg(playerUnit:GetHeading()) + + for _, info in ipairs(activeClients) do + local otherUnit = info.unit + if otherUnit and otherUnit:IsAlive() and otherUnit:GetCoalition() == playerCoalition and otherUnit:GetName() ~= playerUnit:GetName() then + local otherCoord = otherUnit:GetCoordinate() + if otherCoord then + local distance = playerPos:Get2DDistance(otherCoord) + if distance <= PILOT_INTUITION_CONFIG.complimentRange then + -- Calculate relative bearing + local bearing = playerPos:GetAngleDegrees(playerPos:GetDirectionVec3(otherCoord)) + local relativeBearing = bearing - playerHeading + relativeBearing = (relativeBearing % 360 + 360) % 360 -- Normalize to 0-360 + + -- Check if head-on (within 30 degrees of front or back, but for pass, focus on front) + local isHeadOn = relativeBearing < 30 or relativeBearing > 330 + + if isHeadOn and distance <= PILOT_INTUITION_CONFIG.headOnWarningRange then + if now - playerData.lastHeadOnWarningTime >= (PILOT_INTUITION_CONFIG.closeFlyingMessageCooldown * playerData.frequencyMultiplier) then + MESSAGE:New(self:GetRandomMessage("headOnWarning"), 10):ToClient(client) + playerData.lastHeadOnWarningTime = now + end + elseif not isHeadOn then + if now - playerData.lastComplimentTime >= (PILOT_INTUITION_CONFIG.closeFlyingMessageCooldown * playerData.frequencyMultiplier) then + MESSAGE:New(self:GetRandomMessage("closeFlyingCompliment"), 10):ToClient(client) + playerData.lastComplimentTime = now + end + end + end + end + end + end +end + +function PilotIntuition:ReportGroundTarget(group, playerUnit, client, placeMarker) + if placeMarker == nil then placeMarker = true end + local now = timer.getTime() + if now - self.lastMessageTime < PILOT_INTUITION_CONFIG.messageCooldown then return end + if not PILOT_INTUITION_CONFIG.activeMessaging then return end + local playerPos = playerUnit:GetCoordinate() + local targetPos = group:GetCoordinate() + local bearing = playerPos:HeadingTo(targetPos) -- Returns heading in degrees as number + + -- Ensure bearing is a valid number + if not bearing or type(bearing) ~= "number" then + bearing = 0 -- Fallback to 0 if bearing is invalid + end + + local distanceMeters = playerPos:Get2DDistance(targetPos) + local playerKey = self:GetPlayerDataKey(playerUnit) + local distance, unit = self:FormatDistance(distanceMeters, playerKey) + local unitType = group:GetUnits()[1]:GetTypeName() + local category = self:ClassifyGroundUnit(unitType) + local groupSize = #group:GetUnits() + local sizeDesc = groupSize == 1 and "single" or (groupSize <= 4 and "group" or "platoon") + + MESSAGE:New(self:GetRandomMessage("groundTargetDetected", {category, sizeDesc, unitType, bearing, distance, unit}), 10):ToClient(client) + self.lastMessageTime = now + + -- Place marker if requested + if placeMarker then + -- Determine marker type: prefer player preference if set, otherwise global config + local markerType = PILOT_INTUITION_CONFIG.markerType + local playerName = playerUnit:GetPlayerName() or playerUnit:GetName() + if self.players[playerName] and self.players[playerName].markerType then + markerType = self.players[playerName].markerType + end + if markerType ~= "none" then + local coord = group:GetCoordinate() + local markerTypePart, color = markerType:match("(%w+)_(%w+)") + if markerTypePart == "smoke" then + if color == "red" then coord:SmokeRed() + elseif color == "green" then coord:SmokeGreen() + elseif color == "blue" then coord:SmokeBlue() + elseif color == "white" then coord:SmokeWhite() + end + elseif markerTypePart == "flare" then + if color == "red" then coord:FlareRed() + elseif color == "green" then coord:FlareGreen() + elseif color == "white" then coord:FlareWhite() + end + end + -- Note: Markers are temporary; Moose doesn't have built-in timed markers, so this is basic + end + end +end + +function PilotIntuition:GetDetectionMultiplier() + -- Basic night detection: reduce range at night (22:00 to 06:00) + local time = timer.getAbsTime() % 86400 + local hour = math.floor(time / 3600) + local isNight = hour >= 22 or hour < 6 + local mult = 1 + if isNight then + mult = mult * PILOT_INTUITION_CONFIG.nightDetectionMultiplier + end + -- TODO: Add weather check if available + return mult +end + +function PilotIntuition:ClassifyGroundUnit(unitType) + unitType = string.lower(unitType) + if string.find(unitType, "tank") or string.find(unitType, "armor") or string.find(unitType, "panzer") then + return "Armor" + elseif string.find(unitType, "infantry") or string.find(unitType, "soldier") then + return "Infantry" + elseif string.find(unitType, "aaa") or string.find(unitType, "flak") or string.find(unitType, "aa") then + return "Anti-Air" + elseif string.find(unitType, "truck") or string.find(unitType, "vehicle") or string.find(unitType, "transport") then + return "Logistics" + else + return "Ground" + end +end + +function PilotIntuition:ProvideDogfightAssist(playerUnit, banditUnit, distance, relativeBearing, lastRelativeBearing, playerData, client, closing, effectiveCooldown) + local now = timer.getTime() + -- Use provided effectiveCooldown (for combat intensity adjustment) or fall back to default + local cooldown = effectiveCooldown or PILOT_INTUITION_CONFIG.dogfightMessageCooldown + if (now - playerData.lastDogfightAssistTime) < (cooldown * playerData.frequencyMultiplier) then + return + end + if not PILOT_INTUITION_CONFIG.activeMessaging then return end + + local playerPos = playerUnit:GetCoordinate() + local banditPos = banditUnit:GetCoordinate() + local playerAlt = playerUnit:GetAltitude() + local banditAlt = banditUnit:GetAltitude() + local altDelta = banditAlt - playerAlt + + -- Check for speed warning + local velocity = playerUnit:GetVelocityKMH() + local speedKnots = velocity * 0.539957 + if speedKnots < PILOT_INTUITION_CONFIG.criticalSpeedThreshold then + MESSAGE:New("Speed critical! Extend!", 10):ToClient(client) + playerData.lastDogfightAssistTime = now + return + end + + -- Target loss detection (front to rear hemisphere) + if lastRelativeBearing and lastRelativeBearing < 90 and relativeBearing > 270 then + MESSAGE:New("Lost visual! Bandit reversing on you!", 10):ToClient(client) + playerData.lastDogfightAssistTime = now + return + end + + -- Check for bandit moving behind (now behind, wasn't before) + if relativeBearing > 150 and relativeBearing < 210 and distance < PILOT_INTUITION_CONFIG.tailWarningRange then + local direction = relativeBearing < 180 and "left" or "right" + MESSAGE:New("Bandit at 6 o'clock! Break " .. direction .. "!", 10):ToClient(client) + playerData.lastDogfightAssistTime = now + return + end + + -- Closure rate warning (check this BEFORE altitude to prioritize immediate threats) + if closing and distance < PILOT_INTUITION_CONFIG.threatHotRange then + MESSAGE:New("Bandit closing fast! Prepare to engage!", 10):ToClient(client) + playerData.lastDogfightAssistTime = now + return + end + + -- Significant position change + if playerData.lastPrimaryTargetBearing then + local bearingChange = math.abs(relativeBearing - playerData.lastPrimaryTargetBearing) + if bearingChange > PILOT_INTUITION_CONFIG.positionChangeThreshold and bearingChange < (360 - PILOT_INTUITION_CONFIG.positionChangeThreshold) then + local clockPos = math.floor(relativeBearing / 30) + 1 + if clockPos > 12 then clockPos = clockPos - 12 end + MESSAGE:New("Bandit moving to " .. clockPos .. " o'clock!", 10):ToClient(client) + playerData.lastDogfightAssistTime = now + playerData.lastPrimaryTargetBearing = relativeBearing + return + end + end + + -- Altitude advantage/disadvantage (lower priority - only if no other callouts triggered) + if math.abs(altDelta) > PILOT_INTUITION_CONFIG.altitudeDeltaThreshold then + local clockPos = math.floor(relativeBearing / 30) + 1 + if clockPos > 12 then clockPos = clockPos - 12 end + local distKM = math.floor(distance / 1000 * 10) / 10 + + if altDelta > 0 then + -- Bandit is higher + local altKFeet = math.floor(altDelta * 3.28084 / 1000) + MESSAGE:New(string.format("Bandit %d o'clock, %.1fkm, %dk above!", clockPos, distKM, altKFeet), 10):ToClient(client) + else + -- You have altitude advantage + local altKFeet = math.floor(math.abs(altDelta) * 3.28084 / 1000) + MESSAGE:New(string.format("Bandit %d o'clock, %.1fkm, you're %dk high!", clockPos, distKM, altKFeet), 10):ToClient(client) + end + playerData.lastDogfightAssistTime = now + end + + playerData.lastPrimaryTargetBearing = relativeBearing +end + +-- Handle engagement (simplified: if player shoots, assume engagement for nearest target) +function PilotIntuition:OnPlayerShot(EventData) + if not self.enabled then return end + local playerUnit = EventData.IniUnit + if not playerUnit then return end + local client = playerUnit:GetClient() + if client then + local unitName = playerUnit:GetName() + if self.players[unitName] then + local playerData = self.players[unitName] + -- Calculate wingmen for extended range + local clients = SET_CLIENT:New():FilterActive():FilterOnce() + local wingmen = 0 + clients:ForEachClient(function(c) + if c and type(c.GetUnit) == "function" then + local u = c:GetUnit() + -- Validate playerUnit still exists before using it + if u and u:IsAlive() and playerUnit and playerUnit:IsAlive() and u:GetCoalition() == playerUnit:GetCoalition() and u:GetName() ~= playerUnit:GetName() then + local playerCoord = playerUnit:GetCoordinate() + local uCoord = u:GetCoordinate() + if playerCoord and uCoord then + local dist = playerCoord:Get2DDistance(uCoord) + if dist <= PILOT_INTUITION_CONFIG.formationRange then + wingmen = wingmen + 1 + end + end + end + end + end) + local multiplier = (wingmen > 0) and (2 * wingmen) or 1 + if multiplier > PILOT_INTUITION_CONFIG.maxMultiplier then + multiplier = PILOT_INTUITION_CONFIG.maxMultiplier + end + local detectionRange = PILOT_INTUITION_CONFIG.airDetectionRange * multiplier + -- Find nearest air target and mark as engaged + local minDist = math.huge + local nearestID = nil + for id, data in pairs(playerData.trackedAirTargets) do + if not data.engaged and data.unit and data.unit:IsAlive() and playerUnit and playerUnit:IsAlive() then + local playerCoord = playerUnit:GetCoordinate() + local targetCoord = data.unit:GetCoordinate() + if playerCoord and targetCoord then + local distance = playerCoord:Get2DDistance(targetCoord) + if distance < minDist then + minDist = distance + nearestID = id + end + end + end + end + if nearestID and minDist <= detectionRange then + playerData.trackedAirTargets[nearestID].engaged = true + playerData.trackedAirTargets[nearestID].lastEngagedTime = timer.getTime() + if PILOT_INTUITION_CONFIG.activeMessaging then + MESSAGE:New(self:GetRandomMessage("dogfightEngaged"), 10):ToClient(client) + end + end + end + end +end + +-- Handle being shot at +function PilotIntuition:OnShotFired(EventData) + if not self.enabled then return end + local shooter = EventData.IniUnit + if not shooter then return end + + -- Check if any player is the target + local clients = SET_CLIENT:New():FilterActive():FilterOnce() + clients:ForEachClient(function(client) + if client and type(client.GetUnit) == "function" then + local playerUnit = client:GetUnit() + if playerUnit and playerUnit:IsAlive() and playerUnit:GetCoalition() ~= shooter:GetCoalition() then + local playerName = playerUnit:GetPlayerName() or playerUnit:GetName() + if self.players[playerName] and self.players[playerName].dogfightAssist then + local playerData = self.players[playerName] + local now = timer.getTime() + if not PILOT_INTUITION_CONFIG.activeMessaging then return end + + -- Check if shot is directed at player (within range and aspect) + local playerCoord = playerUnit:GetCoordinate() + local shooterCoord = shooter:GetCoordinate() + if not playerCoord or not shooterCoord then + return + end + local distance = playerCoord:Get2DDistance(shooterCoord) + if distance < 1500 and (now - playerData.lastDogfightAssistTime) >= (PILOT_INTUITION_CONFIG.dogfightMessageCooldown * playerData.frequencyMultiplier) then + local bearing = playerCoord:GetAngleDegrees(playerCoord:GetDirectionVec3(shooterCoord)) + local playerHeading = math.deg(playerUnit:GetHeading()) + local relativeBearing = (bearing - playerHeading + 360) % 360 + + -- Determine evasion direction + local direction = "left" + if relativeBearing > 180 then + direction = "right" + end + + -- Check if above or below + local altDelta = shooter:GetAltitude() - playerUnit:GetAltitude() + local vertical = "" + if altDelta > 100 then + vertical = " Push!" + elseif altDelta < -100 then + vertical = " Pull!" + end + + MESSAGE:New(self:GetRandomMessage("underFire", {direction, vertical})):ToClient(client) + playerData.lastDogfightAssistTime = now + end + end + end + end + end) +end + +-- Initialize the Pilot Intuition system +PILog(LOG_INFO, "====== PILOT INTUITION SYSTEM STARTING ======") +local pilotIntuitionSystem = PilotIntuition:New() +PILog(LOG_INFO, "====== PILOT INTUITION SYSTEM INITIALIZED ======") \ No newline at end of file diff --git a/README.md b/README.md index 5b1cafa..8b13789 100644 --- a/README.md +++ b/README.md @@ -1,293 +1 @@ -# MOOSE Dual Coalition Zone Capture System -- **Author**: F99th-TracerFacer -- **Discord:** https://discord.gg/NdZ2JuSU (The Fighting 99th Discord Server where I spend most of my time.) -A dynamic zone capture and control system for DCS World missions using the MOOSE framework. This script enables territory-based gameplay where RED and BLUE coalitions compete to capture and hold strategic zones across the battlefield. - - - - - - -## 🎯 Features - -- **🎨 Visual Feedback**: Color-coded zone boundaries (Red/Blue/Green/Orange) that change dynamically -- **💨 Smoke Signals**: Automatic smoke markers indicating zone status -- **📍 Tactical Information**: Real-time force composition and MGRS coordinates for enemies -- **🏆 Victory Conditions**: Automatic win detection when one coalition captures all zones -- **📻 F10 Radio Menu**: Player-accessible status reports and progress tracking -- **⚙️ Highly Configurable**: Simple zone ownership configuration via Lua tables -- **🔄 Dual Coalition**: Full support for both RED and BLUE coalitions -- **📊 Auto-Reporting**: Periodic status updates every 5 minutes -- **🎮 Player-Friendly**: Clear messaging and intuitive state transitions - -## 🚀 Quick Start - -### Prerequisites - -1. **DCS World** (version 2.9 or higher) -2. **MOOSE Framework** ([Download here](https://github.com/FlightControl-Master/MOOSE)) -3. Basic knowledge of DCS Mission Editor - -### Installation - -1. **Download the files:** - - `Moose_DualCoalitionZoneCapture.lua` - Main script - - `Moose_DualCoalitionZoneCapture.miz` - Example mission - - `Moose_.lua` - MOOSE framework (get latest version) - -2. **In DCS Mission Editor:** - - Create trigger zones for each capture point (e.g., "Capture Zone-1", "Capture Severomorsk") - - Create two groups: `BLUEHQ` (any BLUE ground unit) and `REDHQ` (any RED ground unit) - -3. **Configure zones** in `Moose_DualCoalitionZoneCapture.lua`: - -```lua -local ZONE_CONFIG = { - RED = { - "Capture Zone-1", - "Capture Zone-2" - }, - BLUE = { - "Capture Zone-3", - "Capture Zone-4" - }, - NEUTRAL = { - -- Empty zones at mission start - } -} -``` - -4. **Load scripts** via Mission Start trigger: - - Action 1: DO SCRIPT FILE → `Moose_.lua` - - Action 2: DO SCRIPT FILE → `Moose_DualCoalitionZoneCapture.lua` - -5. **Save and test** your mission! - -## 📖 How It Works - -### Zone States - -Zones transition between four distinct states: - -| State | Color | Smoke | Description | -|-------|-------|-------|-------------| -| **RED Controlled** | 🔴 Red Border | Red | Zone secured by RED coalition | -| **BLUE Controlled** | 🔵 Blue Border | Blue | Zone secured by BLUE coalition | -| **Neutral/Empty** | 🟢 Green Border | Green | Uncontrolled, ready for capture | -| **Contested** | 🟠 Orange Border | White | Multiple coalitions present - fighting for control | - -### Capture Mechanics - -- **To Capture**: Move ground units into a zone -- **To Hold**: Eliminate all enemy forces in the zone -- **To Win**: Capture ALL zones on the map - -The script automatically scans zones every 30 seconds (configurable) and updates ownership based on unit presence. - -### Tactical Information Markers - -Each zone displays real-time tactical data: - -``` -TACTICAL: Capture Severomorsk-1 -Forces: R:5 B:12 -TGTS: T-90@38U LV 12345 67890, BTR-80@38U LV 12346 67891 -``` - -- **Force Counts**: Number of units per coalition -- **MGRS Coordinates**: Precise enemy locations (when ≤10 units) -- **Coalition-Specific**: Each side sees their enemies marked - -## ⚙️ Configuration Options - -### Zone Settings - -```lua -local ZONE_SETTINGS = { - guardDelay = 1, -- Seconds before entering Guard state after capture - scanInterval = 30, -- How often to scan for units (seconds) - captureScore = 200 -- Points awarded for zone capture -} -``` - -### Performance Tuning - -For missions with many units: -```lua -scanInterval = 60 -- Scan less frequently -``` - -For fast-paced action: -```lua -scanInterval = 15 -- More responsive zone changes -``` - -### Logging Control - -Disable detailed logging: -```lua -CAPTURE_ZONE_LOGGING = { enabled = false } -``` - -## 👥 Player Features - -### F10 Radio Menu Commands - -Players access zone information via **F10 → Zone Control**: - -- **Get Zone Status Report**: Current ownership of all zones -- **Check Victory Progress**: Percentage toward victory -- **Refresh Zone Colors**: Manually redraw zone boundaries - -### Automatic Notifications - -- ✅ Zone capture/loss announcements -- ⚠️ Attack warnings when zones are contested -- 📊 Status reports every 5 minutes -- 🏆 Victory alerts at 80% and 100% completion -- 🎉 Victory countdown with celebratory effects - -## 🎮 Example Mission - -The included `Moose_DualCoalitionZoneCapture.miz` demonstrates: - -- Proper zone configuration -- HQ group placement -- Script loading order -- AI patrol patterns for testing -- All visual and messaging features - -**Use this mission as a template for your own scenarios!** - -## 🔧 Troubleshooting - -### Common Issues - -#### ❌ Script Won't Load -**Error**: "attempt to index a nil value" -- **Cause**: MOOSE not loaded first -- **Fix**: Ensure load order is MOOSE → Capture Script - -#### ❌ Zone Not Found -**Error**: "Zone 'X' not found in mission editor!" -- **Cause**: Zone name mismatch -- **Fix**: Verify zone names match EXACTLY (case-sensitive!) - -#### ⚠️ Zones Not Capturing -- Only ground units count. -- Wait 30 seconds for scan cycle -- Eliminate ALL enemy forces to capture -- Check DCS.log for detailed information -- Increase logging and capture logs before reaching out to me. Happy to help troubleshoot. Discord info below. - -### Checking Logs - -Open `Saved Games\DCS\Logs\DCS.log` and search for: -- `[CAPTURE Module]` - General logging -- `[INIT]` - Initialization messages -- `[TACTICAL]` - Tactical marker updates -- `[VICTORY]` - Victory condition checks - -## 🏗️ Mission Design Tips - -### Best Practices - -- **Zone Size**: Large enough for tactical areas, avoid overlaps -- **Zone Placement**: Position over airbases, FOBs, strategic terrain -- **Starting Balance**: Consider defensive vs. offensive scenarios -- **AI Behavior**: Use "Ground Hold" or "Ground On Road" waypoints -- **Player Briefing**: Document F10 menu commands in mission brief - -### Integration with Other Scripts - -Access zone data from other scripts: - -```lua --- Get current ownership status -local status = GetZoneOwnershipStatus() --- Returns: { blue = X, red = Y, neutral = Z, total = N, zones = {...} } - --- Manual status broadcast -BroadcastZoneStatus() - --- Refresh zone visuals -RefreshAllZoneColors() -``` - -### Victory Flags - -The script sets user flags on victory: -- `BLUE_VICTORY = 1` when BLUE wins -- `RED_VICTORY = 1` when RED wins - -Use these in triggers to end missions or transition to next phase. - -## 📋 Requirements - -### Essential Components - -- ✅ DCS World 2.9 or higher -- ✅ MOOSE Framework (latest version) -- ✅ Trigger zones in mission editor -- ✅ BLUEHQ and REDHQ groups - -### Mission Prerequisites - -- At least one trigger zone per capture point -- Exact zone name matching between editor and Lua config -- Both HQ groups must exist (can be hidden/inactive) - -## 📞 Support & Resources - -### Get Help - -- **F99th Mission Maker Discord Community**: https://discord.gg/kTNmMScQNf -- **Author**: F99th-TracerFacer -- **GitHub Issues**: Report bugs or request features: https://github.com/users/iTracerFacer/projects/3 - - -### Additional Resources - -- [MOOSE Documentation](https://flightcontrol-master.github.io/MOOSE_DOCS/) -- [MOOSE Discord](https://discord.gg/gj68fm969S) -- [DCS Forums](https://forum.dcs.world) - -## 📄 License - -This script is provided free for use in DCS World missions. Feel free to modify and distribute. - -## 🙏 Credits - -- **Author**: F99th-TracerFacer -- **Discord:** https://discord.gg/NdZ2JuSU (The Fighting 99th Discord Server where I spend most of my time.) -- **Framework**: MOOSE by FlightControl -- **Community**: DCS World Mission Makers - -## 🎯 Version History - -### Version 2.0 (Current) -- ✨ Full dual coalition support (RED & BLUE) -- ✨ Tactical information markers with MGRS coordinates -- ✨ Auto-victory detection and countdown -- ✨ F10 radio menu commands -- ✨ Periodic status reports -- ✨ Enhanced visual feedback system -- ✨ Configurable zone ownership via Lua tables - -### Version 1.0 -- Initial release -- Basic zone capture mechanics -- Single coalition focus - ---- - -