Travel Airways Log Bot (GeoFS)

Logs flights with crash detection, auto ICAO detection, session recovery & terrain-based AGL check for Travel Airways

// ==UserScript==
// @name         Travel Airways Log Bot (GeoFS)
// @namespace    https://airtravel.neocities.org/flightlogger
// @version      1.2.2
// @description  Logs flights with crash detection, auto ICAO detection, session recovery & terrain-based AGL check for Travel Airways
// @match        http://*/geofs.php*
// @match        https://*/geofs.php*
// @author       31124呀
// @grant        none
// ==/UserScript==

(function () {
  'use strict';

  const WEBHOOK_URL = "https://discord.com/api/webhooks/1411315862048084020/Kr-E6vo1tGucV3JFlLoMPE-atz0btZUtIfWTWQ6sqqrxKQ7MPY4chpsWhvqmz3FtY_Cx";
  const STORAGE_KEY = "geofs_flight_logger_session";

  const SALARY_CONFIG = {
    baseRate: 800, // 基础时薪(元/小时)
    nightBonus: 1.2, // 夜间飞行加成
    internationalBonus: 1.5, // 国际航班加成
    butterBonus: 200, // 完美着陆奖金
    hardPenalty: -100, // 硬着陆罚款
    crashPenalty: -500000, // 坠机罚款
    minFlightTime: 0, // 最小计费时间(小时)
    nightHours: [22, 6] // 夜间时间段 [开始小时, 结束小时]
  };

  let flightStarted = false;
  let flightStartTime = null;
  let departureICAO = "UNKNOWN";
  let arrivalICAO = "UNKNOWN";
  let hasLanded = false;
  let monitorInterval = null;
  let firstGroundContact = false;
  let firstGroundTime = null;
  let panelUI, startButton, callsignInput;
  let airportsDB = [];
  let departureAirportData = null;
  let arrivalAirportData = null;
  let isPanelVisible = true;
  let isDragging = false;
  let dragOffsetX = 0;
  let dragOffsetY = 0;

fetch("https://raw.githubusercontent.com/mwgg/Airports/master/airports.json")
  .then(r => r.json())
  .then(data => {
    airportsDB = Object.entries(data).map(([icao, info]) => ({
      icao,
      lat: info.lat,
      lon: info.lon,
      tz: info.tz || null,
      name: info.name || "",
      city: info.city || "",
      country: info.country || ""
    }));
    console.log(`✅ Loaded ${airportsDB.length} airports`);
  })
  .catch(err => console.error("❌ Airport DB load failed:", err));

  function getNearestAirport(lat, lon) {
    if (!airportsDB.length) return { icao: "UNKNOWN" };
    let nearest = null, minDist = Infinity;
    for (const ap of airportsDB) {
      const dLat = (ap.lat - lat) * Math.PI / 180;
      const dLon = (ap.lon - lon) * Math.PI / 180;
      const a = Math.sin(dLat/2) ** 2 +
        Math.cos(lat * Math.PI/180) * Math.cos(ap.lat * Math.PI/180) *
        Math.sin(dLon/2) ** 2;
      const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
      const dist = 6371 * c;
      if (dist < minDist) {
        minDist = dist;
        nearest = ap;
      }
    }
    if (nearest && minDist > 30) return null;
    return nearest || null;
  }

  function saveSession() {
    const session = {
      flightStarted,
      flightStartTime,
      departureICAO,
      callsign: callsignInput?.value.trim() || "Unknown",
      firstGroundContact,
      departureAirportData,
      timestamp: Date.now()
    };
    localStorage.setItem(STORAGE_KEY, JSON.stringify(session));
  }

  function clearSession() {
    localStorage.removeItem(STORAGE_KEY);
  }

  function promptForAirportICAO(type, lat, lon) {
    const locationStr = `${lat.toFixed(4)}, ${lon.toFixed(4)}`;
    const icao = prompt(`❓ ${type} airport not found in database.\nLocation: ${locationStr}\n\nPlease enter the ICAO code manually (or leave empty for UNKNOWN):`);
    return icao ? icao.toUpperCase().trim() : "UNKNOWN";
  }

  function getAircraftName() {
    let raw = geofs?.aircraft?.instance?.aircraftRecord?.name || "Unknown";
    return raw.replace(/^\([^)]*\)\s*/, ""); // 去掉 (作者名) 部分
  }

  function formatTimeWithTimezone(timestamp, airportData) {
    let timeZone = 'UTC';
    let suffix = 'UTC';

    if (airportData && airportData.tz) {
      timeZone = airportData.tz;
      const date = new Date(timestamp);
      const timezoneName = date.toLocaleDateString('en', {
        timeZone: timeZone,
        timeZoneName: 'short'
      }).split(', ')[1] || timeZone.split('/')[1] || 'LT';
      suffix = timezoneName;
    }

    const fmt = new Intl.DateTimeFormat('en-GB', {
      timeZone: timeZone,
      day: '2-digit',
      month: 'short',
      year: 'numeric',
      hour: '2-digit',
      minute: '2-digit',
      hour12: false
    });

    return `${fmt.format(new Date(timestamp))} ${suffix}`;
  }

  function calculateSalary(flightData) {
    const flightHours = flightData.durationHours;
    const landingQuality = flightData.landingQuality;
    const takeoffTime = new Date(flightData.takeoff);
    const landingTime = new Date(flightData.landing);

    let baseSalary = Math.max(flightHours * SALARY_CONFIG.baseRate,
                             SALARY_CONFIG.minFlightTime * SALARY_CONFIG.baseRate);

    const takeoffHour = takeoffTime.getHours();
    const landingHour = landingTime.getHours();
    const isNightFlight = takeoffHour >= SALARY_CONFIG.nightHours[0] ||
                         takeoffHour < SALARY_CONFIG.nightHours[1] ||
                         landingHour >= SALARY_CONFIG.nightHours[0] ||
                         landingHour < SALARY_CONFIG.nightHours[1];

    if (isNightFlight) {
      baseSalary *= SALARY_CONFIG.nightBonus;
    }

    const isInternational = (departureAirportData && arrivalAirportData &&
                           departureAirportData.country !== arrivalAirportData.country);

    if (isInternational) {
      baseSalary *= SALARY_CONFIG.internationalBonus;
    }

    let landingBonus = 0;
    switch(landingQuality) {
      case "BUTTER":
        landingBonus = SALARY_CONFIG.butterBonus;
        break;
      case "HARD":
        landingBonus = SALARY_CONFIG.hardPenalty;
        break;
      case "CRASH":
        landingBonus = SALARY_CONFIG.crashPenalty;
        break;
    }

    const totalSalary = Math.max(0, baseSalary + landingBonus);

    return {
      base: Math.round(baseSalary),
      bonus: landingBonus,
      total: Math.round(totalSalary),
      isNight: isNightFlight,
      isInternational: isInternational,
      currency: "CNY"
    };
  }

  function sendLogToDiscord(data, salaryData) {
    const takeoffTime = formatTimeWithTimezone(data.takeoff, departureAirportData);
    const landingTime = formatTimeWithTimezone(data.landing, arrivalAirportData);

    let embedColor;
    switch(data.landingQuality) {
      case "BUTTER": embedColor = 0x00FF00; break;
      case "HARD": embedColor = 0xFF8000; break;
      case "CRASH": embedColor = 0xFF0000; break;
      default: embedColor = 0x0099FF; break;
    }

    // 构建工资信息字符串,包含奖金信息
    let salaryValue = `**Total**: ${salaryData.total} CNY`;
    if (salaryData.bonus !== 0) {
      salaryValue += `\n**Bonus**: ${salaryData.bonus > 0 ? '+' : ''}${salaryData.bonus} CNY`;
    }

    const message = {
      embeds: [{
        title: "🛫 Flight Report - GeoFS",
        color: embedColor,
        fields: [
          {
            name: "✈️ Flight Information",
            value: `**Flight no.**: ${data.pilot}\n**Pilot name**: ${geofs?.userRecord?.callsign || "Unknown"}\n**Aircraft**: ${data.aircraft}`,
            inline: false
          },
          {
            name: "📍 Route",
            value: `**Departure**: ${data.dep}\n**Arrival**: ${data.arr}`,
            inline: true
          },
          {
            name: "⏱️ Duration",
            value: `**Flight Time**: ${data.duration}`,
            inline: true
          },
          {
            name: "📊 Flight Data",
            value: `**V/S**: ${data.vs} fpm\n**G-Force**: ${data.gforce}\n**TAS**: ${data.ktrue} kts\n**GS**: ${data.gs} kts`,
            inline: true
          },
          {
            name: "🏁 Landing Quality",
            value: `**${data.landingQuality}**`,
            inline: true
          },
          {
            name: "💰 Salary",
            value: salaryValue,
            inline: true
          },
          {
            name: "🕓 Times",
            value: `**Takeoff**: ${takeoffTime}\n**Landing**: ${landingTime}`,
            inline: false
          }
        ],
        timestamp: new Date().toISOString(),
        footer: {
          text: "GeoFS Flight Logger"
        }
      }]
    };

    fetch(WEBHOOK_URL, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(message)
    }).then(() => console.log("✅ Flight log sent"))
      .catch(console.error);
  }

  function showToast(message, type = 'info', duration = 3000) {
    const toast = document.createElement('div');
    Object.assign(toast.style, {
      position: 'fixed',
      top: '20px',
      right: '20px',
      padding: '12px 20px',
      borderRadius: '8px',
      color: 'white',
      fontWeight: 'bold',
      fontSize: '14px',
      fontFamily: 'sans-serif',
      zIndex: '10001',
      minWidth: '300px',
      boxShadow: '0 4px 12px rgba(0,0,0,0.3)',
      opacity: '0',
      transform: 'translateX(100%)',
      transition: 'all 0.3s ease-in-out',
      textAlign: 'center'
    });

    switch(type) {
      case 'crash':
        toast.style.background = 'linear-gradient(135deg, #ff4444, #cc0000)';
        break;
      case 'success':
        toast.style.background = 'linear-gradient(135deg, #00C851, #007E33)';
        break;
      case 'warning':
        toast.style.background = 'linear-gradient(135deg, #ffbb33, #FF8800)';
        break;
      case 'salary':
        toast.style.background = 'linear-gradient(135deg, #33b5e5, #0099CC)';
        break;
      default:
        toast.style.background = 'linear-gradient(135deg, #2E86C1, #1B4F72)';
    }

    toast.innerHTML = message;
    document.body.appendChild(toast);

    setTimeout(() => {
      toast.style.opacity = '1';
      toast.style.transform = 'translateX(0)';
    }, 10);

    setTimeout(() => {
      toast.style.opacity = '0';
      toast.style.transform = 'translateX(100%)';
      setTimeout(() => {
        if (document.body.contains(toast)) document.body.removeChild(toast);
      }, 300);
    }, duration);
  }

  function monitorFlight() {
    if (!geofs?.animation?.values || !geofs.aircraft?.instance) return;
    const values = geofs.animation.values;
    const onGround = values.groundContact;
    const altitudeFt = values.altitude * 3.28084;
    const terrainFt = geofs.api?.map?.getTerrainAltitude?.() * 3.28084 || 0;
    const agl = altitudeFt - terrainFt;
    const [lat, lon] = geofs.aircraft.instance.llaLocation || [values.latitude, values.longitude];
    const now = Date.now();

    if (!flightStarted && !onGround && agl > 100) {
      flightStarted = true;
      flightStartTime = now;
      const nearestAirport = getNearestAirport(lat, lon);
      if (nearestAirport) {
        departureICAO = nearestAirport.icao;
        departureAirportData = nearestAirport;
      } else {
        departureICAO = promptForAirportICAO("Departure", lat, lon);
        departureAirportData = null;
      }
      saveSession();
      console.log(`🛫 Departure detected at ${departureICAO}`);
      showToast("🛫 起飞检测成功<br>开始记录飞行数据", 'success');
      if (panelUI) {
        panelUI.style.opacity = "0";
        setTimeout(() => panelUI.style.display = "none", 500);
      }
    }

    const elapsed = (now - flightStartTime) / 1000;
    if (flightStarted && !firstGroundContact && onGround) {
      if (elapsed < 1) return;
      const vs = values.verticalSpeed;

      if (vs <= -800) {
        showToast("💥 坠机检测<br>记录事故报告...", 'crash', 4000);
        const nearestAirport = getNearestAirport(lat, lon);
        if (nearestAirport) {
          arrivalICAO = "Crash";
          arrivalAirportData = nearestAirport;
        } else {
          arrivalICAO = "Crash";
          arrivalAirportData = null;
        }
      } else {
        const nearestAirport = getNearestAirport(lat, lon);
        if (nearestAirport) {
          arrivalICAO = nearestAirport.icao;
          arrivalAirportData = nearestAirport;
        } else {
          arrivalICAO = promptForAirportICAO("Arrival", lat, lon);
          arrivalAirportData = null;
        }
      }

      console.log(`🛬 Arrival detected at ${arrivalICAO}`);
      firstGroundContact = true;
      firstGroundTime = now;

      const g = (values.accZ / 9.80665).toFixed(2);
      const gs = values.groundSpeedKnt.toFixed(1);
      const tas = geofs.aircraft.instance.trueAirSpeed?.toFixed(1) || "N/A";
      const quality = (vs > -60) ? "BUTTER" : (vs > -800) ? "HARD" : "CRASH";
      const baseCallsign = callsignInput.value.trim() || "Unknown";
      const pilot = baseCallsign.toUpperCase().startsWith("TRA") ?
        baseCallsign : `TRA${baseCallsign}`;
      const aircraft = getAircraftName();
      const durationMin = Math.round((firstGroundTime - flightStartTime) / 60000);
      const durationHours = durationMin / 60;

      const hours = Math.floor(durationMin / 60);
      const minutes = durationMin % 60;
      const formattedDuration = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`;

      const flightDataForSalary = {
        durationHours: durationHours,
        landingQuality: quality,
        takeoff: flightStartTime,
        landing: firstGroundTime
      };

      const salaryData = calculateSalary(flightDataForSalary);

      sendLogToDiscord({
        pilot, aircraft,
        takeoff: flightStartTime,
        landing: firstGroundTime,
        dep: departureICAO,
        arr: arrivalICAO,
        duration: formattedDuration,
        durationHours: durationHours,
        vs: vs.toFixed(1),
        gforce: g,
        gs: gs,
        ktrue: tas,
        landingQuality: quality
      }, salaryData);

      let salaryMessage = `💰 工资结算: ${salaryData.total} CNY`;
      if (salaryData.bonus > 0) {
        salaryMessage += `<br>🎉 奖金: +${salaryData.bonus} CNY`;
      } else if (salaryData.bonus < 0) {
        salaryMessage += `<br>⚠️ 罚款: ${salaryData.bonus} CNY`;
      }

      if (salaryData.isNight) salaryMessage += "<br>🌙 包含夜间飞行加成";
      if (salaryData.isInternational) salaryMessage += "<br>🌍 包含国际航班加成";

      showToast(salaryMessage, 'salary', 5000);

      saveSession();
      clearSession();
      resetPanel();

      if (monitorInterval) {
        clearInterval(monitorInterval);
        monitorInterval = null;
      }
    }
  }

  function resetPanel() {
    flightStarted = false;
    hasLanded = false;
    firstGroundContact = false;
    flightStartTime = null;
    departureICAO = "UNKNOWN";
    arrivalICAO = "UNKNOWN";
    departureAirportData = null;
    arrivalAirportData = null;
    callsignInput.value = "";
    startButton.disabled = true;
    startButton.innerText = "📋 开始飞行记录";
    if (panelUI) {
      panelUI.style.display = "block";
      panelUI.style.opacity = "0.8";
    }
  }

  function disableKeyPropagation(input) {
    ["keydown", "keyup", "keypress"].forEach(ev =>
      input.addEventListener(ev, e => e.stopPropagation())
    );
  }

  function togglePanelVisibility() {
    isPanelVisible = !isPanelVisible;
    if (panelUI) {
      if (isPanelVisible) {
        panelUI.style.display = "block";
        setTimeout(() => {
          panelUI.style.opacity = "0.8";
        }, 10);
      } else {
        panelUI.style.opacity = "0";
        setTimeout(() => {
          panelUI.style.display = "none";
        }, 500);
      }
    }
  }

  function createSidePanel() {
    panelUI = document.createElement("div");
    Object.assign(panelUI.style, {
      position: "fixed",
      top: "80px",
      left: "20px",
      background: "linear-gradient(135deg, #1a1a1a, #2d2d2d)",
      color: "white",
      padding: "15px",
      border: "2px solid #00C8FF",
      zIndex: "10000",
      width: "280px",
      fontSize: "14px",
      fontFamily: "'Segoe UI', Tahoma, Geneva, Verdana, sans-serif",
      transition: "all 0.3s ease",
      display: "block",
      opacity: "0.8",
      cursor: "move",
      borderRadius: "12px",
      boxShadow: "0 8px 32px rgba(0, 0, 0, 0.3)",
      backdropFilter: "blur(10px)"
    });

    const titleBar = document.createElement("div");
    titleBar.innerHTML = `
      <div style="display: flex; align-items: center; justify-content: space-between;">
        <div style="display: flex; align-items: center;">
          <span style="font-size: 18px; font-weight: bold; color: #00C8FF;">✈️ Travel Airways</span>
        </div>
        <div style="font-size: 12px; color: #888;">按 W 显示/隐藏</div>
      </div>
    `;
    titleBar.style.padding = "10px 15px";
    titleBar.style.margin = "-15px -15px 15px -15px";
    titleBar.style.background = "linear-gradient(135deg, #2d2d2d, #1a1a1a)";
    titleBar.style.cursor = "move";
    titleBar.style.borderRadius = "10px 10px 0 0";
    titleBar.style.userSelect = "none";
    titleBar.style.borderBottom = "2px solid #00C8FF";
    panelUI.appendChild(titleBar);

    const airlineLabel = document.createElement("div");
    airlineLabel.textContent = "航空公司: Travel Airways (TRA)";
    airlineLabel.style.marginBottom = "15px";
    airlineLabel.style.fontSize = "12px";
    airlineLabel.style.color = "#00C8FF";
    airlineLabel.style.textAlign = "center";
    airlineLabel.style.padding = "8px";
    airlineLabel.style.background = "rgba(0, 200, 255, 0.1)";
    airlineLabel.style.borderRadius = "6px";
    airlineLabel.style.border = "1px solid rgba(0, 200, 255, 0.3)";
    panelUI.appendChild(airlineLabel);

    const inputContainer = document.createElement("div");
    inputContainer.style.marginBottom = "15px";

    const inputLabel = document.createElement("div");
    inputLabel.textContent = "航班号 (数字部分):";
    inputLabel.style.marginBottom = "5px";
    inputLabel.style.color = "#00C8FF";
    inputLabel.style.fontSize = "12px";
    inputContainer.appendChild(inputLabel);

    callsignInput = document.createElement("input");
    callsignInput.placeholder = "例如: 123 → TRA123";
    callsignInput.style.width = "100%";
    callsignInput.style.padding = "10px";
    callsignInput.style.border = "1px solid #444";
    callsignInput.style.borderRadius = "6px";
    callsignInput.style.background = "rgba(255, 255, 255, 0.1)";
    callsignInput.style.color = "white";
    callsignInput.style.outline = "none";
    callsignInput.style.transition = "all 0.3s ease";
    callsignInput.addEventListener("focus", () => {
      callsignInput.style.borderColor = "#00C8FF";
      callsignInput.style.background = "rgba(255, 255, 255, 0.15)";
    });
    callsignInput.addEventListener("blur", () => {
      callsignInput.style.borderColor = "#444";
      callsignInput.style.background = "rgba(255, 255, 255, 0.1)";
    });
    disableKeyPropagation(callsignInput);
    callsignInput.onkeyup = () => {
      startButton.disabled = callsignInput.value.trim() === "";
      startButton.style.background = callsignInput.value.trim() === ""
        ? "linear-gradient(135deg, #666, #555)"
        : "linear-gradient(135deg, #00C851, #007E33)";
    };
    inputContainer.appendChild(callsignInput);
    panelUI.appendChild(inputContainer);

    startButton = document.createElement("button");
    startButton.innerText = "📋 开始飞行记录";
    startButton.disabled = true;
    Object.assign(startButton.style, {
      width: "100%",
      padding: "12px",
      background: "linear-gradient(135deg, #666, #555)",
      color: "white",
      border: "none",
      cursor: "pointer",
      borderRadius: "6px",
      fontSize: "14px",
      fontWeight: "bold",
      transition: "all 0.3s ease",
      marginBottom: "10px"
    });

    startButton.addEventListener("mouseover", function() {
      if (!this.disabled) {
        this.style.transform = "translateY(-2px)";
        this.style.boxShadow = "0 4px 12px rgba(0, 200, 133, 0.3)";
      }
    });

    startButton.addEventListener("mouseout", function() {
      this.style.transform = "translateY(0)";
      this.style.boxShadow = "none";
    });

    startButton.onclick = () => {
      showToast("✅ 飞行记录已启动<br>准备起飞...", 'success');
      monitorInterval = setInterval(monitorFlight, 1000);
      startButton.innerText = "🟢 记录中...";
      startButton.disabled = true;
      startButton.style.background = "linear-gradient(135deg, #007E33, #005a25)";
    };

    panelUI.appendChild(startButton);

    const salaryInfo = document.createElement("div");
    salaryInfo.innerHTML = `
      <div style="background: rgba(0, 200, 255, 0.05); padding: 10px; border-radius: 6px; border: 1px solid rgba(0, 200, 255, 0.2);">
        <div style="color: #00C8FF; font-size: 12px; margin-bottom: 5px;">💰 工资标准:</div>
        <div style="font-size: 11px; color: #aaa; line-height: 1.4;">
          • 基础: ${SALARY_CONFIG.baseRate} CNY/小时<br>
          • 夜间: ×${SALARY_CONFIG.nightBonus}<br>
          • 国际: ×${SALARY_CONFIG.internationalBonus}<br>
          • 完美着陆: +${SALARY_CONFIG.butterBonus} CNY
        </div>
      </div>
    `;
    panelUI.appendChild(salaryInfo);

    document.body.appendChild(panelUI);

    titleBar.addEventListener('mousedown', function(e) {
      isDragging = true;
      dragOffsetX = e.clientX - panelUI.getBoundingClientRect().left;
      dragOffsetY = e.clientY - panelUI.getBoundingClientRect().top;
      panelUI.style.cursor = "grabbing";
      panelUI.style.boxShadow = "0 12px 40px rgba(0, 0, 0, 0.4)";
    });

    document.addEventListener('mousemove', function(e) {
      if (isDragging) {
        const x = e.clientX - dragOffsetX;
        const y = e.clientY - dragOffsetY;

        const maxX = window.innerWidth - panelUI.offsetWidth;
        const maxY = window.innerHeight - panelUI.offsetHeight;

        panelUI.style.left = Math.max(0, Math.min(x, maxX)) + 'px';
        panelUI.style.top = Math.max(0, Math.min(y, maxY)) + 'px';
      }
    });

    document.addEventListener('mouseup', function() {
      isDragging = false;
      panelUI.style.cursor = "move";
      panelUI.style.boxShadow = "0 8px 32px rgba(0, 0, 0, 0.3)";
    });
  }

  window.addEventListener("load", () => {
    console.log("✅ Travel Airways Flight Logger Loaded");
    createSidePanel();

    document.addEventListener('keydown', function(e) {
      if (e.key.toLowerCase() === 'w' && !e.ctrlKey && !e.altKey && !e.shiftKey) {
        e.preventDefault();
        togglePanelVisibility();
      }
    });
  });
})();