Attack_Keeper

Testi

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         Attack_Keeper
// @namespace    tm-grepolis-attack-keeper
// @version      1.0.0
// @author       Chepa
// @copyright    2026
// @description  Testi
// @match        https://*.grepolis.com/game/*
// @exclude      forum.*.grepolis.*/*
// @exclude      wiki.*.grepolis.*/*
// @grant        none
// @run-at       document-idle
// ==/UserScript==

(function () {
  "use strict";

  const SCRIPT_ID = "tm-grepolis-attack-keeper";
  const STORAGE_PREFIX = "tm_grepolis_attack_keeper_v1";
  const STORAGE_STATE_KEY = `${STORAGE_PREFIX}:state`;
  const STORAGE_LOCK_KEY = `${STORAGE_PREFIX}:lock`;
  const CHANNEL_NAME = `${STORAGE_PREFIX}:channel`;
  const HEARTBEAT_MS = 1000;
  const LOCK_TTL_MS = 15000;
  const AJAX_TIMEOUT_MS = 10000;
  const COMMAND_TIMEOUT_MS = 5000;
  const COMMAND_RESOLVE_TIMEOUT_MS = 15000;
  const BINDINGS_WAIT_TIMEOUT_MS = 30000;
  const BINDINGS_POLL_MS = 250;
  const DEBUG_LOGGING = true;
  const LOG_LIMIT = 100;
  const ATTACK_INTERVAL_MS = 4 * 60 * 1000 + 30 * 1000;
  const SHORT_RANGE_ATTACK_INTERVAL_MS = 3 * 60 * 1000;
  const PRESSURE_WINDOW_MS = 5 * 60 * 1000;
  const CANCELLATION_LOCK_MS = 10 * 60 * 1000;
  const CANCELLATION_SAFETY_MS = 5000;
  const PRE_CANCEL_SWITCH_MS = 5000;
  const SHORT_RANGE_CANCEL_EARLIEST_MS = 60 * 1000;
  const SHORT_RANGE_CANCEL_LATEST_MS = 30 * 1000;
  const RETRY_AFTER_ERROR_MS = 15000;
  const EXECUTION_JITTER_MS = 4000;
  const HUMAN_DELAY_MIN_MS = 1000;
  const HUMAN_DELAY_MAX_MS = 4000;
  const REQUEST_NL_INIT = true;
  const UNIT_INPUT_MIN = 0;
  const UNIT_INPUT_MAX = 999999;
  const OWNER_ID_SESSION_KEY = `${STORAGE_PREFIX}:owner_id`;
  const SUPPORTED_ATTACK_TYPES = new Set(["attack"]);
  const SERVER_TIME_SELECTOR = "#server_time_area, .server_time_area";
  const STATIC_UNIT_ORDER = [
    "sword",
    "slinger",
    "archer",
    "hoplite",
    "rider",
    "chariot",
    "catapult",
    "minotaur",
    "manticore",
    "zyklop",
    "cerberus",
    "harpy",
    "medusa",
    "griffin",
    "calydonian_boar",
    "fury",
    "pegasus",
    "satyr",
    "godsent",
    "big_transporter",
    "small_transporter",
    "bireme",
    "attack_ship",
    "demolition_ship",
    "trireme",
    "colonize_ship",
    "sea_monster",
    "hydra",
    "siren",
    "spartoi",
    "ladon",
  ];

  const rootWindow = window;
  if (!rootWindow || window.__tmGrepolisAttackKeeperLoaded) {
    if (DEBUG_LOGGING) {
      console.info(`[${SCRIPT_ID}] skipped bootstrap`, {
        hasRootWindow: Boolean(rootWindow),
        alreadyLoaded: Boolean(window.__tmGrepolisAttackKeeperLoaded),
      });
    }
    return;
  }
  window.__tmGrepolisAttackKeeperLoaded = true;

  const state = {
    bindings: null,
    ownerId: getOwnerId(),
    serverClockOffsetMs: 0,
    unitCatalog: [],
    data: null,
    ui: {
      launcher: null,
      wnd: null,
      frame: null,
      sourceTown: null,
      targetTownId: null,
      endAt: null,
      serverTime: null,
      status: null,
      nextSend: null,
      activeLabel: null,
      unitsGrid: null,
      unitsInputs: new Map(),
      unitsAvailable: new Map(),
      start: null,
      stop: null,
      banner: null,
      log: null,
    },
    timers: {
      heartbeat: null,
      clock: null,
    },
    heartbeatBusy: false,
    channel: null,
  };

  bootstrap().catch((error) => {
    console.error(`[${SCRIPT_ID}] bootstrap failed`, error);
  });

  function debugLog(message, extra) {
    if (!DEBUG_LOGGING) {
      return;
    }
    if (extra === undefined) {
      console.info(`[${SCRIPT_ID}] ${message}`);
      return;
    }
    console.info(`[${SCRIPT_ID}] ${message}`, extra);
  }

  function debugWarn(message, extra) {
    if (!DEBUG_LOGGING) {
      return;
    }
    if (extra === undefined) {
      console.warn(`[${SCRIPT_ID}] ${message}`);
      return;
    }
    console.warn(`[${SCRIPT_ID}] ${message}`, extra);
  }

  function debugError(message, extra) {
    if (!DEBUG_LOGGING) {
      return;
    }
    if (extra === undefined) {
      console.error(`[${SCRIPT_ID}] ${message}`);
      return;
    }
    console.error(`[${SCRIPT_ID}] ${message}`, extra);
  }

  async function bootstrap() {
    debugLog("bootstrap start", { href: location.href });
    await waitForBindings();
    state.bindings = collectGameBindings();
    debugLog("bindings ready", {
      townId: state.bindings.Game && state.bindings.Game.townId,
      hasITowns: Boolean(state.bindings.ITowns),
      hasGpAjax: Boolean(state.bindings.gpAjax),
      hasLayout: Boolean(state.bindings.Layout),
    });
    state.serverClockOffsetMs = computeServerClockOffsetMs();
    state.unitCatalog = buildUnitCatalog();
    state.data = loadState();
    ensureUi();
    debugLog("ui injected", {
      launcherPresent: Boolean(document.getElementById(`${SCRIPT_ID}-launcher`)),
      unitCount: state.unitCatalog.length,
    });
    bindUi();
    bindCrossTabEvents();
    renderAll();
    startClock();
    await resumeRunIfNeeded();
    debugLog("bootstrap complete");
  }

  function collectGameBindings() {
    const bindings = {
      window: rootWindow,
      ITowns: rootWindow.ITowns,
      GPWindowMgr: rootWindow.GPWindowMgr,
      Timestamp: rootWindow.Timestamp,
      gpAjax: rootWindow.gpAjax,
      Game: rootWindow.Game,
      GameData: rootWindow.GameData,
      HelperTown: rootWindow.HelperTown,
      Layout: rootWindow.Layout,
    };
    const missing = Object.entries(bindings)
      .filter(([key, value]) => key !== "window" && !value)
      .map(([key]) => key);
    if (missing.length) {
      throw new Error(`Missing Grepolis bindings: ${missing.join(", ")}`);
    }
    return bindings;
  }

  async function waitForBindings() {
    const deadline = Date.now() + BINDINGS_WAIT_TIMEOUT_MS;
    let lastError = null;
    let lastReportAt = 0;
    while (Date.now() < deadline) {
      try {
        collectGameBindings();
        return;
      } catch (error) {
        lastError = error;
        if (Date.now() - lastReportAt >= 5000) {
          lastReportAt = Date.now();
          debugWarn("waiting for Grepolis bindings", normalizeError(error));
        }
        await sleep(BINDINGS_POLL_MS);
      }
    }
    debugError("bindings wait timed out", lastError);
    throw lastError || new Error("Grepolis bindings were not exposed in time.");
  }

  function buildUnitCatalog() {
    const gameUnits = state.bindings.GameData && state.bindings.GameData.units ? state.bindings.GameData.units : {};
    const seen = new Set();
    const keys = [];
    STATIC_UNIT_ORDER.forEach((key) => {
      if (gameUnits[key] && shouldShowUnit(key, gameUnits[key])) {
        seen.add(key);
        keys.push(key);
      }
    });
    Object.keys(gameUnits)
      .filter((key) => !seen.has(key) && shouldShowUnit(key, gameUnits[key]))
      .sort((left, right) => getUnitLabel(left).localeCompare(getUnitLabel(right)))
      .forEach((key) => keys.push(key));
    return keys.map((key) => ({
      key,
      label: getUnitLabel(key),
      category: getUnitCategory(gameUnits[key]),
    }));
  }

  function shouldShowUnit(key, unitData) {
    if (!unitData) {
      return false;
    }
    if (key === "militia" || key === "colonize_ship") {
      return false;
    }
    return true;
  }

  function getUnitCategory(unitData) {
    if (!unitData) {
      return "unit";
    }
    if (unitData.is_naval) {
      return "naval";
    }
    if (unitData.is_mythical) {
      return "mythical";
    }
    return "ground";
  }

  function getUnitLabel(key) {
    const unitData = state.bindings.GameData.units && state.bindings.GameData.units[key];
    if (unitData && typeof unitData.name === "string" && unitData.name.trim()) {
      return unitData.name.trim();
    }
    return key
      .split("_")
      .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
      .join(" ");
  }

  function getOwnerId() {
    try {
      const cached = sessionStorage.getItem(OWNER_ID_SESSION_KEY);
      if (cached) {
        return cached;
      }
      const next = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
      sessionStorage.setItem(OWNER_ID_SESSION_KEY, next);
      return next;
    } catch (_error) {
      return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
    }
  }

  function defaultState() {
    return {
      profile: {
        sourceTownId: null,
        targetTownId: null,
        units: {},
        endAt: null,
        active: false,
      },
      runtime: {
        active: false,
        startedAt: null,
        nextSendAt: null,
        waveSequence: 0,
        pendingCommands: [],
        lastError: "",
        ownerId: null,
      },
      recentLog: [],
    };
  }

  function loadState() {
    const fallback = defaultState();
    const parsed = safeParseJson(localStorage.getItem(STORAGE_STATE_KEY));
    if (!parsed || typeof parsed !== "object") {
      return fallback;
    }
    const profile = normalizeProfile(parsed.profile || fallback.profile);
    const runtime = normalizeRuntime(parsed.runtime || fallback.runtime);
    const recentLog = Array.isArray(parsed.recentLog) ? parsed.recentLog.slice(-LOG_LIMIT).map(normalizeLogEntry) : [];
    return {
      profile,
      runtime,
      recentLog,
    };
  }

  function normalizeProfile(profile) {
    const normalizedUnits = {};
    const rawUnits = profile && typeof profile.units === "object" ? profile.units : {};
    Object.keys(rawUnits).forEach((key) => {
      const value = toInteger(rawUnits[key], 0);
      if (value > 0) {
        normalizedUnits[key] = value;
      }
    });
    return {
      sourceTownId: toNullableInteger(profile.sourceTownId),
      targetTownId: toNullableInteger(profile.targetTownId),
      units: normalizedUnits,
      endAt: Number.isFinite(profile.endAt) ? profile.endAt : null,
      active: Boolean(profile.active),
    };
  }

  function normalizeRuntime(runtime) {
    const pendingCommands = Array.isArray(runtime.pendingCommands)
      ? runtime.pendingCommands.map(normalizePendingCommand).filter(Boolean)
      : [];
    return {
      active: Boolean(runtime.active),
      startedAt: Number.isFinite(runtime.startedAt) ? runtime.startedAt : null,
      nextSendAt: Number.isFinite(runtime.nextSendAt) ? runtime.nextSendAt : null,
      waveSequence: clamp(toInteger(runtime.waveSequence, 0), 0, 999999),
      pendingCommands,
      lastError: typeof runtime.lastError === "string" ? runtime.lastError : "",
      ownerId: typeof runtime.ownerId === "string" ? runtime.ownerId : null,
    };
  }

  function normalizePendingCommand(entry) {
    if (!entry || typeof entry !== "object") {
      return null;
    }
    const sourceTownId = toNullableInteger(entry.sourceTownId);
    const targetTownId = toNullableInteger(entry.targetTownId);
    const sentAt = Number.isFinite(entry.sentAt) ? entry.sentAt : null;
    const commandId = toNullableInteger(entry.commandId);
    if (!commandId && (!sourceTownId || !targetTownId || !sentAt)) {
      return null;
    }
    return {
      commandId,
      sourceTownId,
      targetTownId,
      sentAt,
      nextCycleAt: Number.isFinite(entry.nextCycleAt) ? entry.nextCycleAt : null,
      plannedCancelAt: Number.isFinite(entry.plannedCancelAt) ? entry.plannedCancelAt : null,
      arrivalAt: Number.isFinite(entry.arrivalAt) ? entry.arrivalAt : null,
      travelDurationMs: Number.isFinite(entry.travelDurationMs) ? entry.travelDurationMs : null,
      expectedType: typeof entry.expectedType === "string" ? entry.expectedType : "",
      cancelBehavior: entry && entry.cancelBehavior === "land" ? "land" : "cancel",
    };
  }

  function normalizeLogEntry(entry) {
    return {
      at: Number.isFinite(entry && entry.at) ? entry.at : nowServerMs(),
      level: entry && entry.level === "error" ? "error" : "info",
      message: entry && typeof entry.message === "string" ? entry.message : "",
    };
  }

  function saveState() {
    localStorage.setItem(STORAGE_STATE_KEY, JSON.stringify(state.data));
  }


  function buildUnitsGrid() {
    state.ui.unitsGrid.innerHTML = "";
    state.ui.unitsInputs.clear();
    state.ui.unitsAvailable.clear();
    state.unitCatalog.forEach((unit) => {
      const row = document.createElement("label");
      row.className = `${SCRIPT_ID}__unitRow`;
      row.dataset.unitKey = unit.key;
      row.title = unit.label;

      const iconWrap = document.createElement("span");
      iconWrap.className = `${SCRIPT_ID}__unitIconWrap`;

      const iconFrame = document.createElement("span");
      iconFrame.className = `${SCRIPT_ID}__unitIconFrame`;

      const icon = document.createElement("div");
      icon.className = getUnitIconClassName(unit.key);

      const iconFallback = document.createElement("span");
      iconFallback.className = `${SCRIPT_ID}__unitIconFallback`;
      iconFallback.textContent = unit.label.slice(0, 2).toUpperCase();

      iconFrame.appendChild(icon);
      iconFrame.appendChild(iconFallback);
      iconWrap.appendChild(iconFrame);

      const meta = document.createElement("span");
      meta.className = `${SCRIPT_ID}__unitMeta`;

      const available = document.createElement("span");
      available.className = `${SCRIPT_ID}__available`;
      available.textContent = "0";

      const input = document.createElement("input");
      input.type = "number";
      input.min = String(UNIT_INPUT_MIN);
      input.max = String(UNIT_INPUT_MAX);
      input.step = "1";
      input.className = `${SCRIPT_ID}__unitInput`;
      input.dataset.unitKey = unit.key;

      row.appendChild(iconWrap);
      meta.appendChild(available);
      meta.appendChild(input);
      row.appendChild(meta);
      state.ui.unitsGrid.appendChild(row);

      state.ui.unitsInputs.set(unit.key, input);
      state.ui.unitsAvailable.set(unit.key, available);
    });
  }

  function getUnitIconClassName(unitKey) {
    return `unit index_unit bold unit_icon40x40 ${unitKey} ${SCRIPT_ID}__unitIcon`;
  }

  function isControlDisabled(element) {
    return !element || element.classList.contains("disabled") || element.getAttribute("aria-disabled") === "true";
  }

  function setControlDisabled(element, disabled) {
    if (!element) {
      return;
    }
    element.classList.toggle("disabled", Boolean(disabled));
    element.setAttribute("aria-disabled", disabled ? "true" : "false");
    if (disabled) {
      element.tabIndex = -1;
      return;
    }
    element.tabIndex = 0;
  }


  function bindCrossTabEvents() {
    if ("BroadcastChannel" in window) {
      state.channel = new BroadcastChannel(CHANNEL_NAME);
      state.channel.addEventListener("message", (event) => {
        handleBroadcastMessage(event.data);
      });
    }
    window.addEventListener("storage", (event) => {
      if (event.key === STORAGE_LOCK_KEY) {
        handleForeignLockChange(event.newValue);
      }
      if (event.key === STORAGE_STATE_KEY && !state.data.runtime.active) {
        state.data = loadState();
        renderAll();
      }
    });
  }



  function clearWindowUiRefs() {
    state.ui.wnd = null;
    state.ui.frame = null;
    state.ui.sourceTown = null;
    state.ui.targetTownId = null;
    state.ui.endAt = null;
    state.ui.serverTime = null;
    state.ui.status = null;
    state.ui.nextSend = null;
    state.ui.activeLabel = null;
    state.ui.unitsGrid = null;
    state.ui.start = null;
    state.ui.stop = null;
    state.ui.banner = null;
    state.ui.log = null;
    state.ui.unitsInputs.clear();
    state.ui.unitsAvailable.clear();
  }

  function getWindowContentHtml() {
    return `
      <div class="${SCRIPT_ID}__native">
        <div class="${SCRIPT_ID}__nativeHeader">
          <h3 class="${SCRIPT_ID}__heading">Settings</h3>
          <div class="${SCRIPT_ID}__serverTime" data-role="server-time"></div>
        </div>
        <div class="${SCRIPT_ID}__layout">
          <div class="${SCRIPT_ID}__leftCol">
            <label class="${SCRIPT_ID}__field">
              <span>Ciudad origen</span>
              <select data-role="source-town"></select>
            </label>
            <label class="${SCRIPT_ID}__field">
              <span>ID ciudad objetivo</span>
              <input data-role="target-town-id" type="number" min="1" step="1" />
            </label>
            <label class="${SCRIPT_ID}__field">
              <span>Hasta (hora servidor)</span>
              <input data-role="end-at" type="text" placeholder="YYYY-MM-DD HH:mm:ss" />
            </label>
            <hr class="${SCRIPT_ID}__rule" />
            <div class="${SCRIPT_ID}__status">
              <div><strong>Estado</strong><span data-role="active-label"></span></div>
              <div><strong>Proximo envio</strong><span data-role="next-send"></span></div>
            </div>
            <div class="${SCRIPT_ID}__actions">
              <div class="button_new" data-role="start" role="button" tabindex="0" aria-disabled="false">
                <div class="left"></div>
                <div class="right"></div>
                <div class="caption js-caption">Iniciar<div class="effect js-effect"></div></div>
              </div>
              <div class="button_new" data-role="stop" role="button" tabindex="0" aria-disabled="false">
                <div class="left"></div>
                <div class="right"></div>
                <div class="caption js-caption">Parar<div class="effect js-effect"></div></div>
              </div>
            </div>
            <div class="${SCRIPT_ID}__logWrap">
              <h4 class="${SCRIPT_ID}__subheading">Registro reciente</h4>
              <div class="${SCRIPT_ID}__log" data-role="log"></div>
            </div>
          </div>
          <div class="${SCRIPT_ID}__rightCol">
            <div class="${SCRIPT_ID}__unitsHeader">
              <div>Tropas</div>
              <div>Disponible / envio exacto</div>
            </div>
            <div class="${SCRIPT_ID}__units" data-role="units-grid"></div>
          </div>
        </div>
      </div>
    `;
  }

  function getWindowFrame(titleText) {
    const titles = Array.from(document.getElementsByClassName("ui-dialog-title"));
    const title = titles.find((element) => element.textContent.trim() === titleText);
    if (!title) {
      return null;
    }
    return title.parentElement.parentElement.children[1].children[4] || null;
  }

  function mountWindowUi(wnd) {
    const frame = getWindowFrame("Attack Keeper");
    if (!frame) {
      throw new Error("Could not find the Grepolis window frame.");
    }
    frame.innerHTML = getWindowContentHtml();
    frame.classList.add(`${SCRIPT_ID}__frame`);

    state.ui.wnd = wnd;
    state.ui.frame = frame;
    state.ui.sourceTown = frame.querySelector("[data-role='source-town']");
    state.ui.targetTownId = frame.querySelector("[data-role='target-town-id']");
    state.ui.endAt = frame.querySelector("[data-role='end-at']");
    state.ui.serverTime = frame.querySelector("[data-role='server-time']");
    state.ui.nextSend = frame.querySelector("[data-role='next-send']");
    state.ui.activeLabel = frame.querySelector("[data-role='active-label']");
    state.ui.unitsGrid = frame.querySelector("[data-role='units-grid']");
    state.ui.start = frame.querySelector("[data-role='start']");
    state.ui.stop = frame.querySelector("[data-role='stop']");
    state.ui.log = frame.querySelector("[data-role='log']");

    buildUnitsGrid();
    bindWindowUi();
    renderAll();
  }

  function bindWindowUi() {
    state.ui.start.addEventListener("click", () => {
      if (isControlDisabled(state.ui.start)) {
        return;
      }
      void handleStart();
    });
    state.ui.stop.addEventListener("click", () => {
      if (isControlDisabled(state.ui.stop)) {
        return;
      }
      void handleStop();
    });
    [state.ui.start, state.ui.stop].forEach((control) => {
      control.addEventListener("keydown", (event) => {
        if (event.key !== "Enter" && event.key !== " ") {
          return;
        }
        event.preventDefault();
        control.click();
      });
    });
    state.ui.sourceTown.addEventListener("change", () => {
      syncProfileFromUi();
      renderAvailability();
    });
    [
      state.ui.targetTownId,
      state.ui.endAt,
    ].forEach((element) => {
      element.addEventListener("input", syncProfileFromUi);
      element.addEventListener("change", syncProfileFromUi);
    });
    state.ui.unitsInputs.forEach((input) => {
      input.addEventListener("input", syncProfileFromUi);
      input.addEventListener("change", syncProfileFromUi);
    });
  }

  function ensureUi() {
    injectStyles();
    const existingLauncher = document.getElementById(`${SCRIPT_ID}-launcher`);
    if (existingLauncher) {
      state.ui.launcher = existingLauncher;
      return;
    }
    const launcher = document.createElement("button");
    launcher.type = "button";
    launcher.id = `${SCRIPT_ID}-launcher`;
    launcher.textContent = "AK";
    document.body.appendChild(launcher);
    state.ui.launcher = launcher;
  }

  function bindUi() {
    if (!state.ui.launcher || state.ui.launcher.dataset.bound === "true") {
      return;
    }
    state.ui.launcher.dataset.bound = "true";
    state.ui.launcher.addEventListener("click", togglePanel);
  }

  function injectStyles() {
    if (document.getElementById(`${SCRIPT_ID}-styles`)) {
      return;
    }
    const style = document.createElement("style");
    style.id = `${SCRIPT_ID}-styles`;
    style.textContent = `
      #${SCRIPT_ID}-launcher {
        position: fixed;
        right: 18px;
        bottom: 18px;
        z-index: 2147483646;
        width: 40px;
        height: 40px;
        border: 1px solid #7b5315;
        background: linear-gradient(180deg, #f5cf72 0%, #b8791f 100%);
        color: #3b2408;
        font: 700 13px/1 Tahoma, Verdana, sans-serif;
        border-radius: 6px;
        box-shadow: inset 0 1px 0 rgba(255, 247, 202, 0.85), 0 3px 10px rgba(0, 0, 0, 0.35);
        cursor: pointer;
      }

      .${SCRIPT_ID}__frame {
        padding: 10px 12px 14px;
        background: #f5deb0;
        color: #000;
        font: 12px/1.35 Arial, sans-serif;
      }

      .${SCRIPT_ID}__native {
        min-height: 560px;
      }

      .${SCRIPT_ID}__nativeHeader {
        display: flex;
        align-items: flex-start;
        justify-content: space-between;
        gap: 16px;
        margin-bottom: 12px;
      }

      .${SCRIPT_ID}__heading,
      .${SCRIPT_ID}__subheading {
        margin: 0;
        color: #000;
        font-weight: 700;
      }

      .${SCRIPT_ID}__heading {
        font-size: 18px;
      }

      .${SCRIPT_ID}__subheading {
        font-size: 13px;
      }

      .${SCRIPT_ID}__serverTime {
        color: #5b4921;
        font-size: 12px;
        white-space: nowrap;
      }

      .${SCRIPT_ID}__layout {
        display: grid;
        grid-template-columns: 320px minmax(0, 1fr);
        gap: 18px;
      }

      .${SCRIPT_ID}__leftCol,
      .${SCRIPT_ID}__rightCol {
        min-width: 0;
      }

      .${SCRIPT_ID}__rule {
        margin: 14px 0;
        border: 0;
        border-top: 1px solid #bfb2a0;
      }

      .${SCRIPT_ID}__field {
        display: block;
        margin-bottom: 12px;
      }

      .${SCRIPT_ID}__field > span {
        display: block;
        margin-bottom: 5px;
        color: #000;
        font-weight: 700;
        text-align: left;
      }

      .${SCRIPT_ID}__field input,
      .${SCRIPT_ID}__field select,
      .${SCRIPT_ID}__unitInput {
        width: 100%;
        box-sizing: border-box;
        height: 28px;
        padding: 4px 6px;
        border: 1px solid #7b6a45;
        border-radius: 0;
        background: #f8edc7;
        color: #000;
        font: 12px/1.2 Arial, sans-serif;
        box-shadow: inset 0 0 0 2px #d8c79b;
      }

      .${SCRIPT_ID}__status {
        margin: 0 0 12px;
        color: #000;
      }

      .${SCRIPT_ID}__status > div {
        display: flex;
        justify-content: space-between;
        gap: 10px;
        color: #000;
      }

      .${SCRIPT_ID}__status > div + div {
        margin-top: 4px;
      }

      .${SCRIPT_ID}__actions {
        display: flex;
        gap: 8px;
        align-items: center;
        margin-top: 6px;
      }

      .${SCRIPT_ID}__actions .button_new {
        margin: 0;
        cursor: pointer;
      }

      .${SCRIPT_ID}__actions .button_new .caption {
        min-width: 74px;
        text-align: center;
        font-weight: 700;
      }

      .${SCRIPT_ID}__actions .button_new.disabled {
        opacity: 0.5;
        cursor: default;
        pointer-events: none;
      }

      .${SCRIPT_ID}__unitsHeader {
        display: flex;
        justify-content: space-between;
        gap: 12px;
        margin-bottom: 8px;
        color: #000;
        font-weight: 700;
      }

      .${SCRIPT_ID}__units {
        display: grid;
        grid-template-columns: repeat(3, minmax(0, 1fr));
        gap: 6px 12px;
      }

      .${SCRIPT_ID}__unitRow {
        display: grid;
        grid-template-columns: 44px 148px;
        gap: 10px;
        align-items: center;
        min-height: 44px;
        padding: 2px 0;
        border: 0;
        border-radius: 0;
        background: transparent;
      }

      .${SCRIPT_ID}__unitIconWrap {
        display: flex;
        align-items: center;
        justify-content: center;
      }

      .${SCRIPT_ID}__unitIconFrame {
        position: relative;
        width: 44px;
        height: 44px;
        overflow: hidden;
      }

      .${SCRIPT_ID}__unitIcon {
        position: absolute;
        top: 1px;
        left: 1px;
        display: block;
        width: 40px;
        height: 40px;
        z-index: 2;
        margin: 0;
        float: none;
        overflow: hidden;
      }

      .${SCRIPT_ID}__unitIconFallback {
        display: none;
      }

      .${SCRIPT_ID}__unitMeta {
        display: grid;
        grid-template-columns: 52px minmax(0, 1fr);
        gap: 6px;
        align-items: center;
      }

      .${SCRIPT_ID}__available {
        color: #000;
        text-align: right;
        font-weight: 700;
      }

      .${SCRIPT_ID}__unitRow--invalid {
        background: transparent;
      }

      .${SCRIPT_ID}__unitRow--invalid .${SCRIPT_ID}__unitInput {
        border-color: #b0482a;
        background: #efc3b5;
        box-shadow: inset 0 0 0 2px rgba(176, 72, 42, 0.2);
      }

      .${SCRIPT_ID}__logWrap {
        margin-top: 14px;
      }

      .${SCRIPT_ID}__log {
        min-height: 124px;
        max-height: 200px;
        overflow: auto;
        border: 1px solid #c8a55b;
        border-radius: 0;
        background: rgba(250, 239, 204, 0.75);
      }

      .${SCRIPT_ID}__logEntry {
        display: grid;
        grid-template-columns: 132px 1fr;
        gap: 10px;
        padding: 7px 9px;
        border-top: 1px solid rgba(123, 106, 69, 0.35);
        color: #000;
      }

      .${SCRIPT_ID}__logEntry:first-child {
        border-top: 0;
      }

      .${SCRIPT_ID}__logEntry[data-level="error"] {
        background: rgba(198, 113, 83, 0.25);
      }

      .${SCRIPT_ID}__logAt {
        color: #4d3a17;
        font-weight: 700;
      }

      .${SCRIPT_ID}__logEmpty {
        padding: 9px;
        color: #5b4921;
        text-align: center;
      }

      @media (max-width: 860px) {
        .${SCRIPT_ID}__layout {
          grid-template-columns: 1fr;
        }

        .${SCRIPT_ID}__units {
          grid-template-columns: repeat(2, minmax(0, 1fr));
        }
      }

      @media (max-width: 560px) {
        .${SCRIPT_ID}__units {
          grid-template-columns: 1fr;
        }
      }
    `;
    document.head.appendChild(style);
  }

  function togglePanel() {
    if (state.ui.frame && document.body.contains(state.ui.frame)) {
      hidePanel();
      return;
    }
    showPanel();
  }

  function showPanel() {
    if (state.ui.frame && document.body.contains(state.ui.frame)) {
      renderAll();
      return;
    }
    const wnd = state.bindings.Layout.wnd.Create(state.bindings.Layout.wnd.TYPE_DIALOG, "Attack Keeper");
    wnd.setContent("");
    wnd.setTitle("Attack Keeper");
    wnd.setWidth("1080");
    wnd.setHeight(String(Math.max(620, Math.min(window.innerHeight - 120, 760))));
    mountWindowUi(wnd);
  }

  function hidePanel() {
    if (state.ui.wnd && typeof state.ui.wnd.close === "function") {
      state.ui.wnd.close();
    }
    clearWindowUiRefs();
  }

  function startClock() {
    if (state.timers.clock) {
      return;
    }
    state.timers.clock = window.setInterval(() => {
      renderServerTime();
      renderStatus();
    }, 1000);
    renderServerTime();
  }

  function renderAll() {
    if (!state.ui.frame || !document.body.contains(state.ui.frame)) {
      return;
    }
    renderTownOptions();
    populateForm();
    renderAvailability();
    renderStatus();
    renderLog();
    renderServerTime();
    renderBanner();
  }

  function renderTownOptions() {
    if (!state.ui.sourceTown) {
      return;
    }
    const towns = getOwnTowns();
    const currentValue = state.ui.sourceTown.value;
    state.ui.sourceTown.innerHTML = "";
    const placeholder = document.createElement("option");
    placeholder.value = "";
    placeholder.textContent = "Selecciona una ciudad";
    state.ui.sourceTown.appendChild(placeholder);
    towns.forEach((town) => {
      const option = document.createElement("option");
      option.value = String(town.id);
      option.textContent = `${town.name} (#${town.id})`;
      state.ui.sourceTown.appendChild(option);
    });
    const preferred = state.data.profile.sourceTownId ? String(state.data.profile.sourceTownId) : currentValue;
    if (preferred && state.ui.sourceTown.querySelector(`option[value="${preferred}"]`)) {
      state.ui.sourceTown.value = preferred;
    }
  }

  function populateForm() {
    if (!state.ui.sourceTown) {
      return;
    }
    const profile = state.data.profile;
    state.ui.sourceTown.value = profile.sourceTownId ? String(profile.sourceTownId) : "";
    state.ui.targetTownId.value = profile.targetTownId ? String(profile.targetTownId) : "";
    state.ui.endAt.value = profile.endAt ? formatServerDateTime(profile.endAt) : "";
    state.ui.unitsInputs.forEach((input, unitKey) => {
      input.value = profile.units[unitKey] ? String(profile.units[unitKey]) : "";
    });
  }

  function renderAvailability() {
    if (!state.ui.sourceTown) {
      return;
    }
    const sourceTownId = toNullableInteger(state.ui.sourceTown.value) || state.data.profile.sourceTownId;
    const town = sourceTownId ? getTown(sourceTownId) : null;
    const availableUnits = town && typeof town.units === "function" ? town.units() : {};
    state.ui.unitsInputs.forEach((input, unitKey) => {
      const available = toInteger(availableUnits[unitKey], 0);
      const configured = toInteger(input.value, 0);
      const row = input.closest(`.${SCRIPT_ID}__unitRow`);
      const label = state.ui.unitsAvailable.get(unitKey);
      if (label) {
        label.textContent = String(available);
      }
      if (row) {
        row.classList.toggle(`${SCRIPT_ID}__unitRow--invalid`, configured > available);
      }
    });
  }

  function renderStatus() {
    if (!state.ui.activeLabel || !state.ui.nextSend) {
      return;
    }
    const runtime = state.data.runtime;
    state.ui.activeLabel.textContent = runtime.active ? "Activo" : "Inactivo";
    state.ui.nextSend.textContent = runtime.nextSendAt && runtime.active
      ? formatServerDateTime(runtime.nextSendAt)
      : "Sin programar";
    setControlDisabled(state.ui.start, runtime.active);
    setControlDisabled(state.ui.stop, !runtime.active && state.data.runtime.pendingCommands.length === 0);
  }

  function renderLog() {
    if (!state.ui.log) {
      return;
    }
    const entries = state.data.recentLog.slice().reverse();
    state.ui.log.innerHTML = "";
    if (!entries.length) {
      const empty = document.createElement("div");
      empty.className = `${SCRIPT_ID}__logEmpty`;
      empty.textContent = "Sin eventos todavia.";
      state.ui.log.appendChild(empty);
      return;
    }
    entries.forEach((entry) => {
      const row = document.createElement("div");
      row.className = `${SCRIPT_ID}__logEntry`;
      row.dataset.level = entry.level;
      const at = document.createElement("div");
      at.className = `${SCRIPT_ID}__logAt`;
      at.textContent = formatServerDateTime(entry.at);
      const message = document.createElement("div");
      message.textContent = entry.message;
      row.appendChild(at);
      row.appendChild(message);
      state.ui.log.appendChild(row);
    });
  }

  function renderServerTime() {
    if (!state.ui.serverTime) {
      return;
    }
    state.ui.serverTime.textContent = `${formatServerDateTime(nowServerMs())}`;
  }

  function renderBanner() {
    return;
  }

  function syncProfileFromUi() {
    state.data.profile = normalizeProfile({
      sourceTownId: toNullableInteger(state.ui.sourceTown.value),
      targetTownId: toNullableInteger(state.ui.targetTownId.value),
      endAt: parseServerDateTimeInput(state.ui.endAt.value),
      active: state.data.runtime.active,
      units: collectUnitsFromUi(),
    });
    saveState();
    renderAvailability();
    renderStatus();
  }

  function collectUnitsFromUi() {
    const units = {};
    state.ui.unitsInputs.forEach((input, key) => {
      const value = clamp(toInteger(input.value, 0), UNIT_INPUT_MIN, UNIT_INPUT_MAX);
      if (value > 0) {
        units[key] = value;
      }
    });
    return units;
  }

  async function handleStart() {
    try {
      debugLog("start requested");
      syncProfileFromUi();
      await prepareStart();
    } catch (error) {
      debugError("start failed", error);
      await stopWithAlert(normalizeError(error), { preservePending: true, cancelPending: false });
    }
  }

  async function prepareStart() {
    ensureBindingsHealthy();
    reconcileInactivePendingCommands();

    if (!acquireTabLock()) {
      throw new Error("Another Grepolis tab currently owns the attack lock.");
    }

    const profile = state.data.profile;
    validateProfile(profile);
    validateAvailableUnits(profile);

    const now = nowServerMs();
    state.data.profile.active = true;
    state.data.runtime.active = true;
    state.data.runtime.startedAt = now;
    state.data.runtime.nextSendAt = null;
    state.data.runtime.ownerId = state.ownerId;
    state.data.runtime.lastError = "";
    appendLog("info", `Run started from ${resolveTownLabel(profile.sourceTownId)} to Town #${profile.targetTownId}.`);

    const firstSent = await sendAttack(profile, now);
    const firstNextSendAt = computeNextSendAt(firstSent.sentAt, firstSent.travelDurationMs);
    const firstEntry = createPendingCommandEntry(state.data.runtime, profile, firstSent, firstNextSendAt);
    state.data.runtime.pendingCommands.push(firstEntry);
    state.data.runtime.nextSendAt = firstNextSendAt;
    appendLog("info", buildSendLogMessage(firstSent, firstEntry));

    saveState();
    renderAll();
    startHeartbeat();
  }

  async function handleStop() {
    const reason = "Stopped by user.";
    debugLog("stop requested");
    appendLog("info", reason);
    await stopRun(reason, {
      alert: false,
      cancelPending: true,
      preservePending: false,
      keepError: false,
    });
  }

  function startHeartbeat() {
    if (state.timers.heartbeat) {
      return;
    }
    state.timers.heartbeat = window.setInterval(() => {
      void heartbeatTick();
    }, HEARTBEAT_MS);
    void heartbeatTick();
  }

  function stopHeartbeat() {
    if (state.timers.heartbeat) {
      window.clearInterval(state.timers.heartbeat);
      state.timers.heartbeat = null;
    }
  }

  async function heartbeatTick() {
    if (state.heartbeatBusy || !state.data.runtime.active) {
      return;
    }
    state.heartbeatBusy = true;
    try {
      ensureBindingsHealthy();
      if (!refreshTabLock()) {
        throw new Error("Lock lost to another Grepolis tab.");
      }
      await reconcilePendingCommands();
      await ensureSourceTownForDueCancellation();
      await cancelDueCommands();
      await maybeSendNextAttack();
      await finalizeIfFinished();
      saveState();
      renderAll();
    } catch (error) {
      debugError("heartbeat failed", error);
      const reason = normalizeError(error);
      if (isRecoverableRuntimeError(reason)) {
        await handleRecoverableRuntimeError(reason);
      } else {
        await stopWithAlert(reason, { preservePending: true, cancelPending: false });
      }
    } finally {
      state.heartbeatBusy = false;
    }
  }

  async function finalizeIfFinished() {
    const now = nowServerMs();
    if (!state.data.runtime.active) {
      return;
    }
    if (state.data.profile.endAt && now >= state.data.profile.endAt && state.data.runtime.pendingCommands.length === 0) {
      appendLog("info", "Cutoff reached. No pending commands remain.");
      await stopRun("", {
        alert: false,
        cancelPending: false,
        preservePending: false,
        keepError: false,
      });
    }
  }

  async function maybeSendNextAttack() {
    const runtime = state.data.runtime;
    const profile = state.data.profile;
    if (!runtime.active || !runtime.nextSendAt || !profile.endAt) {
      return;
    }
    const now = nowServerMs();
    if (runtime.nextSendAt >= profile.endAt) {
      return;
    }
    if (runtime.nextSendAt > now) {
      return;
    }

    const cycleAt = runtime.nextSendAt;
    const sent = await sendAttack(profile, cycleAt);
    const nextSendAt = computeNextSendAt(sent.sentAt, sent.travelDurationMs);
    const entry = createPendingCommandEntry(runtime, profile, sent, nextSendAt);
    runtime.pendingCommands.push(entry);
    runtime.nextSendAt = nextSendAt;
    appendLog("info", buildSendLogMessage(sent, entry));
  }

  async function sendAttack(profile, cycleAt) {
    await switchToTownIfNeeded(profile.sourceTownId);
    validateProfile(profile);
    validateAvailableUnits(profile);

    const beforeSnapshot = snapshotOutgoingMovements();
    const attackData = await openAttackWindow(profile);
    const travelDurationMs = extractTravelDurationMs(attackData);
    const payload = buildAttackPayload(profile, attackData);
    const sentAt = nowServerMs();

    appendLog(
      "info",
      `Dispatching attack from ${resolveTownLabel(profile.sourceTownId)} to Town #${profile.targetTownId} for cycle ${formatServerDateTime(cycleAt)}.`
    );
    debugLog("dispatching attack", {
      sourceTownId: profile.sourceTownId,
      targetTownId: profile.targetTownId,
      cycleAt,
      payload,
    });

    let sendCallbackTimedOut = false;
    let response = null;
    try {
      await humanDelay("before send_units");
      response = await ajaxPost("town_info", "send_units", payload);
      if (response && response.success === false) {
        throw new Error(extractAjaxMessage(response) || "send_units returned success=false.");
      }
    } catch (error) {
      const message = normalizeError(error);
      if (message === "Request timed out: town_info/send_units") {
        sendCallbackTimedOut = true;
        appendLog("info", "send_units timed out waiting for callback. Checking outgoing movements.");
      } else {
        throw error;
      }
    }

    const createdFromResponse = extractCreatedCommandFromResponse(response, {
      sourceTownId: profile.sourceTownId,
      targetTownId: profile.targetTownId,
      sentAt,
      travelDurationMs,
      beforeSnapshot,
    });
    if (createdFromResponse) {
      appendLog("info", `Resolved command ${createdFromResponse.commandId} directly from send_units response.`);
      return {
        commandId: createdFromResponse.commandId,
        sentAt,
        arrivalAt: createdFromResponse.arrivalAt || (travelDurationMs ? sentAt + travelDurationMs : null),
        travelDurationMs,
        expectedType: createdFromResponse.type || "attack",
      };
    }

    try {
      const created = await resolveCreatedCommand({
        beforeSnapshot,
        sentAt,
        sourceTownId: profile.sourceTownId,
        targetTownId: profile.targetTownId,
      }, sendCallbackTimedOut ? COMMAND_RESOLVE_TIMEOUT_MS + 10000 : COMMAND_RESOLVE_TIMEOUT_MS);

      if (sendCallbackTimedOut) {
        appendLog("info", `send_units callback timed out, but command ${created.commandId} was detected in movements.`);
      }

      return {
        commandId: created.commandId,
        sentAt,
        arrivalAt: created.arrivalAtMs || (travelDurationMs ? sentAt + travelDurationMs : null),
        travelDurationMs: created.arrivalAtMs ? Math.max(0, created.arrivalAtMs - sentAt) : travelDurationMs,
        expectedType: created.type,
      };
    } catch (error) {
      const resolutionMessage = normalizeError(error);
      const responseMessage = extractAjaxMessage(response);
      const details = [
        "Attack dispatch could not be confirmed.",
        responseMessage ? `Server message: ${responseMessage}.` : "",
        resolutionMessage ? `Resolver message: ${resolutionMessage}.` : "",
        `Source ${resolveTownLabel(profile.sourceTownId)} -> Town #${profile.targetTownId}.`,
      ].filter(Boolean);
      throw new Error(details.join(" "));
    }
  }

  async function openAttackWindow(profile) {
    await humanDelay("before attack window");
    const response = await ajaxGet("town_info", "attack", {
      id: profile.targetTownId,
      town_id: profile.sourceTownId,
      nl_init: REQUEST_NL_INIT,
    });
    if (!response || !response.json) {
      throw new Error("Attack window payload was empty.");
    }
    const data = response.json;
    const responseMessage = extractAjaxMessage(response) || extractAjaxMessage(data);
    if (response.success === false || data.success === false) {
      throw new Error(responseMessage || "Attack window request failed.");
    }
    if (data.controller_type !== "town_info") {
      throw new Error(responseMessage || `Unexpected attack controller: ${data.controller_type || "unknown"}`);
    }
    if (!SUPPORTED_ATTACK_TYPES.has(data.type)) {
      throw new Error(responseMessage || `Unsupported attack type: ${data.type || "unknown"}`);
    }
    if (toNullableInteger(data.target_id) !== profile.targetTownId) {
      throw new Error(responseMessage || "Attack window target does not match the configured target.");
    }
    return data;
  }

  function buildAttackPayload(profile, attackData) {
    const payload = {
      id: profile.targetTownId,
      type: "attack",
      town_id: profile.sourceTownId,
      nl_init: REQUEST_NL_INIT,
    };
    const configuredUnits = profile.units;
    const availableUnits = attackData.units || {};
    let totalUnits = 0;

    Object.keys(configuredUnits).forEach((unitKey) => {
      const requested = configuredUnits[unitKey];
      const availableRecord = availableUnits[unitKey];
      const available = availableRecord && Number.isFinite(availableRecord.count) ? availableRecord.count : toInteger(availableRecord, 0);
      if (requested > 0) {
        if (available < requested) {
          throw new Error(`Unit ${getUnitLabel(unitKey)} is no longer available in the requested amount.`);
        }
        payload[unitKey] = requested;
        totalUnits += requested;
      }
    });

    if (!totalUnits) {
      throw new Error("At least one unit must be configured.");
    }

    if (Array.isArray(attackData.strategies) && attackData.strategies.length) {
      payload.attacking_strategy = attackData.strategies.slice();
    }
    if (attackData.spell && attackData.spell !== "no_power") {
      payload.power_id = attackData.spell;
    }
    return payload;
  }

  async function resolveCreatedCommand(context, timeoutMs) {
    const deadline = Date.now() + (Number.isFinite(timeoutMs) ? timeoutMs : COMMAND_TIMEOUT_MS);
    while (Date.now() < deadline) {
      const afterSnapshot = snapshotOutgoingMovements();
      const matches = afterSnapshot.movements
        .filter((movement) => isResolvableAttackMovement(movement))
        .filter((movement) => !context.beforeSnapshot.sourceRefs.has(movement.sourceRef))
        .map((movement) => ({
          movement,
          score: scoreMovementCandidate(movement, context),
        }))
        .filter((entry) => entry.score > 0)
        .sort((left, right) => right.score - left.score)
        .map((entry) => entry.movement);

      if (matches.length) {
        return matches[0];
      }
      await sleep(250);
    }
    throw new Error("Could not resolve the created command from the command toolbar.");
  }

  function extractCreatedCommandFromResponse(response, context) {
    const candidates = [];

    function visit(value, path) {
      if (!value || typeof value !== "object") {
        return;
      }
      if (Array.isArray(value)) {
        value.forEach((entry, index) => visit(entry, `${path}[${index}]`));
        return;
      }

      const commandId = toNullableInteger(
        value.command_id !== undefined ? value.command_id :
        value.commandId !== undefined ? value.commandId :
        null
      );
      const targetTownId = toNullableInteger(
        value.target_town_id !== undefined ? value.target_town_id :
        value.targetTownId !== undefined ? value.targetTownId :
        value.to_town_id !== undefined ? value.to_town_id :
        value.destination_town_id !== undefined ? value.destination_town_id :
        null
      );
      const sourceTownId = toNullableInteger(
        value.home_town_id !== undefined ? value.home_town_id :
        value.homeTownId !== undefined ? value.homeTownId :
        value.from_town_id !== undefined ? value.from_town_id :
        value.town_id !== undefined ? value.town_id :
        null
      );
      const type = String(
        value.type !== undefined ? value.type :
        value.command_type !== undefined ? value.command_type :
        value.movement_type !== undefined ? value.movement_type :
        ""
      );
      const arrivalAt = toNullableInteger(
        value.arrival_at !== undefined ? value.arrival_at :
        value.arrivalAt !== undefined ? value.arrivalAt :
        value.arrival_timestamp !== undefined ? value.arrival_timestamp :
        value.destination_time !== undefined ? value.destination_time :
        null
      );

      if (commandId) {
        let score = 10;
        if (targetTownId && targetTownId === context.targetTownId) {
          score += 4;
        }
        if (sourceTownId && sourceTownId === context.sourceTownId) {
          score += 4;
        }
        if (isAttackMovementType(type)) {
          score += 2;
        }
        candidates.push({
          commandId,
          targetTownId,
          sourceTownId,
          type,
          arrivalAt: arrivalAt && arrivalAt < 1000000000000 ? arrivalAt * 1000 : arrivalAt,
          score,
          path,
        });
      }

      Object.keys(value).forEach((key) => visit(value[key], path ? `${path}.${key}` : key));
    }

    visit(response, "");
    if (!candidates.length) {
      return null;
    }
    const plausibleCandidates = candidates.filter((candidate) => isPlausibleCreatedCommandCandidate(candidate, context));
    plausibleCandidates.sort((left, right) => right.score - left.score);
    debugLog("send_units response command candidates", candidates);
    debugLog("send_units plausible command candidates", plausibleCandidates);
    return plausibleCandidates.length ? plausibleCandidates[0] : null;
  }

  function isPlausibleCreatedCommandCandidate(candidate, context) {
    if (!candidate || !context) {
      return false;
    }
    if (candidate.commandId && context.beforeSnapshot && context.beforeSnapshot.commandIds.has(candidate.commandId)) {
      return false;
    }
    const trackedCommandIds = new Set(
      state.data.runtime.pendingCommands
        .map((entry) => entry.commandId)
        .filter(Boolean)
    );
    if (candidate.commandId && trackedCommandIds.has(candidate.commandId)) {
      return false;
    }
    if (candidate.targetTownId && context.targetTownId && candidate.targetTownId !== context.targetTownId) {
      return false;
    }
    if (candidate.sourceTownId && context.sourceTownId && candidate.sourceTownId !== context.sourceTownId) {
      return false;
    }
    if (candidate.type && !isAttackMovementType(candidate.type)) {
      return false;
    }
    if (Number.isFinite(candidate.arrivalAt) && Number.isFinite(context.sentAt) && Number.isFinite(context.travelDurationMs)) {
      const expectedArrivalAt = context.sentAt + context.travelDurationMs;
      const deltaMs = Math.abs(candidate.arrivalAt - expectedArrivalAt);
      if (deltaMs > 120000) {
        return false;
      }
    }
    return true;
  }

  function extractTravelDurationMs(value) {
    const directMs = normalizeDurationCandidate(
      readNestedValue(value, [
        ["duration"],
        ["travel_duration"],
        ["travelDuration"],
        ["travel_time"],
        ["travelTime"],
        ["time_to_target"],
        ["timeToTarget"],
        ["command", "duration"],
        ["json", "duration"],
      ])
    );
    if (directMs) {
      return directMs;
    }

    let best = null;
    visitObject(value, (entry, path) => {
      const pathText = path.join(".").toLowerCase();
      if (!pathText) {
        return;
      }
      if (
        pathText.includes("duration") ||
        pathText.includes("travel") ||
        pathText.includes("runtime") ||
        pathText.includes("time_to_target")
      ) {
        const candidate = normalizeDurationCandidate(entry);
        if (candidate && (!best || candidate < best)) {
          best = candidate;
        }
      }
    });
    return best;
  }

  async function cancelDueCommands() {
    const now = nowServerMs();
    const due = state.data.runtime.pendingCommands
      .filter((entry) => entry.plannedCancelAt && entry.plannedCancelAt <= now)
      .sort((left, right) => left.plannedCancelAt - right.plannedCancelAt);

    for (const entry of due) {
      if (!entry.commandId) {
        const resolved = resolvePendingCommandEntry(entry);
        if (resolved) {
          entry.commandId = resolved.commandId;
          entry.arrivalAt = resolved.arrivalAtMs || entry.arrivalAt;
          entry.travelDurationMs = entry.arrivalAt && entry.sentAt ? entry.arrivalAt - entry.sentAt : entry.travelDurationMs;
        }
      }
      if (!entry.commandId) {
        entry.plannedCancelAt = null;
        appendLog("info", `Command from ${formatServerDateTime(entry.sentAt)} could not be resolved in time. It will be allowed to land.`);
        continue;
      }
      await cancelTrackedCommand(entry);
    }
  }

  async function ensureSourceTownForDueCancellation() {
    const runtime = state.data.runtime;
    if (!runtime || !Array.isArray(runtime.pendingCommands) || !runtime.pendingCommands.length) {
      return;
    }
    const now = nowServerMs();
    const imminent = runtime.pendingCommands
      .filter((entry) => Number.isFinite(entry.plannedCancelAt))
      .filter((entry) => entry.plannedCancelAt - now <= PRE_CANCEL_SWITCH_MS)
      .sort((left, right) => left.plannedCancelAt - right.plannedCancelAt)[0];
    if (!imminent || !imminent.sourceTownId) {
      return;
    }
    if (toNullableInteger(state.bindings.Game.townId) === imminent.sourceTownId) {
      return;
    }
    await switchToTownIfNeeded(imminent.sourceTownId, { humanDelayEnabled: false });
  }

  async function cancelTrackedCommand(entry) {
    const live = findMovementByCommandId(entry.commandId);
    if (!live) {
      appendLog("info", `Command ${entry.commandId} is no longer visible in the client. Attempting direct cancellation from the saved source town.`);
    }
    appendLog("info", `Cancelling command ${entry.commandId}.`);
    try {
      await cancelCommand(entry);
      removePendingCommand(entry.commandId);
      appendLog("info", `Command ${entry.commandId} cancelled.`);
    } catch (error) {
      const reason = normalizeError(error);
      if (isMissingCommandCancellationError(reason) || (entry.arrivalAt && nowServerMs() >= entry.arrivalAt)) {
        removePendingCommand(entry.commandId);
        appendLog("info", `Command ${entry.commandId} could not be cancelled because it is no longer available. It was dropped from tracking.`);
        return;
      }
      throw error;
    }
  }

  async function cancelCommand(entry) {
    const sourceTownId = entry.sourceTownId || state.data.profile.sourceTownId;
    await ajaxPost("command_info", "cancel_command", {
      id: entry.commandId,
      town_id: sourceTownId,
    });
    const gone = await waitForCommandToDisappear(entry.commandId, sourceTownId, COMMAND_TIMEOUT_MS);
    if (!gone) {
      throw new Error(`Command ${entry.commandId} did not disappear after cancellation.`);
    }
  }

  async function waitForCommandToDisappear(commandId, townId, timeoutMs) {
    const deadline = Date.now() + timeoutMs;
    while (Date.now() < deadline) {
      const stillExists = await commandExistsInOverview(commandId, townId);
      if (!stillExists) {
        return true;
      }
      await sleep(250);
    }
    return false;
  }

  async function commandExistsInOverview(commandId, townId) {
    if (!commandId || !townId) {
      return false;
    }
    const response = await ajaxGet("town_overviews", "command_overview", {
      town_id: townId,
      nl_init: REQUEST_NL_INIT,
    });
    return responseContainsCommandId(response, commandId);
  }

  function responseContainsCommandId(response, commandId) {
    const needle = String(commandId);
    let found = false;
    visitObject(response, (entry) => {
      if (found || entry === null || entry === undefined) {
        return;
      }
      if (typeof entry === "number" && String(entry) === needle) {
        found = true;
        return;
      }
      if (typeof entry === "string" && entry.includes(needle)) {
        found = true;
      }
    });
    return found;
  }

  async function reconcilePendingCommands() {
    const nextPending = [];
    for (const entry of state.data.runtime.pendingCommands) {
      if (!entry.commandId) {
        const resolved = resolvePendingCommandEntry(entry);
        if (resolved) {
          entry.commandId = resolved.commandId;
          entry.arrivalAt = resolved.arrivalAtMs || entry.arrivalAt;
          entry.travelDurationMs = entry.arrivalAt && entry.sentAt ? entry.arrivalAt - entry.sentAt : entry.travelDurationMs;
          entry.expectedType = resolved.type || entry.expectedType;
          if (entry.plannedCancelAt === null && entry.cancelBehavior !== "land") {
            entry.plannedCancelAt = computePlannedCancelAt(entry.sentAt, entry.arrivalAt, entry.travelDurationMs, entry.cancelBehavior);
          }
          appendLog("info", `Resolved pending attack ${entry.commandId} after dispatch.`);
        }
      }

      const live = entry.commandId ? findMovementByCommandId(entry.commandId) : null;
      if (live) {
        entry.arrivalAt = live.arrivalAtMs || entry.arrivalAt;
        entry.travelDurationMs = entry.arrivalAt && entry.sentAt ? entry.arrivalAt - entry.sentAt : entry.travelDurationMs;
        nextPending.push(entry);
      } else {
        if (!entry.commandId && entry.arrivalAt && nowServerMs() < entry.arrivalAt) {
          nextPending.push(entry);
        } else if (entry.commandId && shouldKeepMissingTrackedCommand(entry)) {
          nextPending.push(entry);
        } else if (!entry.commandId && entry.arrivalAt && nowServerMs() >= entry.arrivalAt) {
          appendLog("info", `Unresolved attack from ${formatServerDateTime(entry.sentAt)} has already landed.`);
        } else {
          appendLog("info", describeMissingTrackedCommand(entry));
        }
      }
    }
    state.data.runtime.pendingCommands = nextPending;
  }

  function describeMissingTrackedCommand(entry) {
    if (!entry || !entry.commandId) {
      return "Tracked command is no longer present and was dropped from tracking.";
    }
    const modelMovement = findMovementModelByCommandId(entry.commandId);
    if (modelMovement) {
      const type = String(
        typeof modelMovement.getType === "function"
          ? modelMovement.getType()
          : modelMovement.attributes && modelMovement.attributes.type
      ).toLowerCase();
      if (type.includes("abort")) {
        return `Command ${entry.commandId} was cancelled and is now returning.`;
      }
      if (type.includes("return")) {
        return `Command ${entry.commandId} is returning and was dropped from active tracking.`;
      }
      return `Command ${entry.commandId} changed state to ${type || "unknown"} and was dropped from active tracking.`;
    }
    if (entry.arrivalAt && nowServerMs() >= entry.arrivalAt) {
      return `Command ${entry.commandId} has already landed.`;
    }
    return `Command ${entry.commandId} is missing from the client models and was dropped from tracking.`;
  }

  function shouldKeepMissingTrackedCommand(entry) {
    if (!entry || !entry.commandId) {
      return false;
    }
    const now = nowServerMs();
    if (Number.isFinite(entry.plannedCancelAt) && entry.plannedCancelAt > now) {
      return true;
    }
    if (Number.isFinite(entry.arrivalAt) && entry.arrivalAt > now) {
      return true;
    }
    return false;
  }

  function reconcileInactivePendingCommands() {
    if (state.data.runtime.active) {
      return;
    }
    const stillLive = state.data.runtime.pendingCommands.filter((entry) => findMovementByCommandId(entry.commandId));
    if (stillLive.length) {
      throw new Error("Tracked live commands from a previous run are still active. Clear them before starting again.");
    }
    if (state.data.runtime.pendingCommands.length) {
      state.data.runtime.pendingCommands = [];
      saveState();
    }
  }

  async function resumeRunIfNeeded() {
    if (!state.data.runtime.active) {
      return;
    }
    if (state.data.runtime.ownerId && state.data.runtime.ownerId !== state.ownerId) {
      const currentLock = getStoredLock();
      if (currentLock && !isLockExpired(currentLock) && currentLock.ownerId !== state.ownerId) {
        appendLog("info", "Run is active in another tab. This tab will remain passive.");
        state.data.runtime.active = false;
        state.data.profile.active = false;
        saveState();
        renderAll();
        return;
      }
    }
    if (!acquireTabLock()) {
      appendLog("info", "Could not reacquire the lock after reload.");
      state.data.runtime.active = false;
      state.data.profile.active = false;
      saveState();
      renderAll();
      return;
    }
    appendLog("info", "Run resumed after page reload.");
    await reconcilePendingCommands();
    saveState();
    renderAll();
    startHeartbeat();
  }

  async function stopWithAlert(reason, options) {
    debugError("stopWithAlert", reason);
    appendLog("error", reason);
    await stopRun(reason, {
      alert: true,
      cancelPending: false,
      preservePending: options && options.preservePending !== undefined ? options.preservePending : true,
      keepError: true,
    });
  }

  async function handleRecoverableRuntimeError(reason) {
    const runtime = state.data.runtime;
    const now = nowServerMs();
    runtime.lastError = reason;
    if (!Number.isFinite(runtime.nextSendAt) || runtime.nextSendAt <= now) {
      runtime.nextSendAt = now + RETRY_AFTER_ERROR_MS;
    }
    appendLog("error", `${reason} Retrying at ${formatServerDateTime(runtime.nextSendAt)}.`);
    saveState();
    renderAll();
  }

  async function stopRun(reason, options) {
    const settings = Object.assign(
      {
        alert: false,
        cancelPending: false,
        preservePending: true,
        keepError: false,
      },
      options || {}
    );

    stopHeartbeat();

    if (settings.cancelPending) {
      const pendingCopy = state.data.runtime.pendingCommands.slice();
      for (const entry of pendingCopy) {
        try {
          if (!entry.commandId) {
            const resolved = resolvePendingCommandEntry(entry);
            if (resolved) {
              entry.commandId = resolved.commandId;
            }
          }
          if (!entry.commandId) {
            appendLog("info", `Pending attack from ${formatServerDateTime(entry.sentAt)} could not be resolved for cancellation.`);
            continue;
          }
          await cancelCommand(entry);
          removePendingCommand(entry.commandId);
        } catch (error) {
          appendLog("error", `Failed to cancel ${entry.commandId}: ${normalizeError(error)}`);
        }
      }
    }

    state.data.profile.active = false;
    state.data.runtime.active = false;
    state.data.runtime.startedAt = null;
    state.data.runtime.nextSendAt = null;
    state.data.runtime.waveSequence = 0;
    state.data.runtime.ownerId = null;
    if (!settings.preservePending) {
      state.data.runtime.pendingCommands = [];
    }
    state.data.runtime.lastError = settings.keepError ? reason : "";

    releaseTabLock();
    saveState();
    renderAll();

    if (settings.alert && reason) {
      showBanner(reason);
    } else if (!settings.keepError) {
      hideBanner();
    }
  }

  function validateProfile(profile) {
    if (!profile.sourceTownId) {
      throw new Error("Select a source town.");
    }
    if (!profile.targetTownId) {
      throw new Error("Target town ID is required.");
    }
    if (profile.sourceTownId === profile.targetTownId) {
      throw new Error("Source and target towns must differ.");
    }
    if (!profile.endAt || profile.endAt <= nowServerMs()) {
      throw new Error("End time must be in the future and expressed in server time.");
    }
    const totalUnits = Object.values(profile.units).reduce((sum, value) => sum + value, 0);
    if (!totalUnits) {
      throw new Error("Configure at least one unit amount.");
    }
    Object.entries(profile.units).forEach(([unitKey, value]) => {
      if (!Number.isInteger(value) || value < 0) {
        throw new Error(`Invalid amount for ${getUnitLabel(unitKey)}.`);
      }
    });
  }

  function validateAvailableUnits(profile) {
    const town = getTown(profile.sourceTownId);
    if (!town) {
      throw new Error(`Source town ${profile.sourceTownId} is not available in ITowns.`);
    }
    const availableUnits = typeof town.units === "function" ? town.units() : {};
    Object.entries(profile.units).forEach(([unitKey, value]) => {
      if (toInteger(availableUnits[unitKey], 0) < value) {
        throw new Error(`Not enough ${getUnitLabel(unitKey)} in ${resolveTownLabel(profile.sourceTownId)}.`);
      }
    });
  }

  function ensureBindingsHealthy() {
    state.bindings = collectGameBindings();
  }

  async function switchToTownIfNeededInternal(townId, options) {
    const targetTownId = toNullableInteger(townId);
    const settings = Object.assign(
      {
        humanDelayEnabled: true,
      },
      options || {}
    );
    if (!targetTownId) {
      throw new Error("Source town is invalid.");
    }
    if (toNullableInteger(state.bindings.Game.townId) === targetTownId) {
      return;
    }

    const helperTown = state.bindings.HelperTown || {};
    const candidates = [
      ["setCurrentTown", state.bindings.ITowns],
      ["switchTown", helperTown],
      ["townSwitch", helperTown],
      ["switchToTown", helperTown],
      ["goToTown", helperTown],
      ["activateTown", helperTown],
    ];

    let switched = false;
    let lastError = null;
    for (const [methodName, context] of candidates) {
      if (!context || typeof context[methodName] !== "function") {
        continue;
      }
      try {
        if (settings.humanDelayEnabled) {
          await humanDelay("before town switch");
        }
        const result = context[methodName](targetTownId);
        if (result && typeof result.then === "function") {
          await result;
        }
        debugLog("town switched locally", {
          fromTownId: toNullableInteger(state.bindings.Game.townId),
          toTownId: targetTownId,
          method: methodName,
        });
        switched = true;
        break;
      } catch (error) {
        lastError = error;
      }
    }

    if (!switched) {
      throw new Error(
        lastError
          ? `Could not switch to source town ${targetTownId}: ${normalizeError(lastError)}`
          : `Could not switch to source town ${targetTownId}. No town switch API was available.`
      );
    }

    const deadline = Date.now() + COMMAND_TIMEOUT_MS;
    while (Date.now() < deadline) {
      if (toNullableInteger(state.bindings.Game.townId) === targetTownId) {
        return;
      }
      ensureBindingsHealthy();
      await sleep(100);
    }
    throw new Error(`Town switch did not complete for source town ${targetTownId}.`);
  }

  async function switchToTownIfNeeded(townId, options) {
    const settings = options === undefined
      ? { humanDelayEnabled: true }
      : options;
    return switchToTownIfNeededInternal(townId, settings);
  }

  function getTown(townId) {
    if (!townId) {
      return null;
    }
    if (typeof state.bindings.ITowns.getTown === "function") {
      return state.bindings.ITowns.getTown(townId);
    }
    return state.bindings.ITowns.towns ? state.bindings.ITowns.towns[townId] : null;
  }

  function getOwnTowns() {
    const raw = state.bindings.ITowns.towns || {};
    return Object.values(raw)
      .map((town) => ({
        id: town.id,
        name: town.name,
      }))
      .filter((town) => town.id && town.name)
      .sort((left, right) => left.name.localeCompare(right.name));
  }

  function resolveTownLabel(townId) {
    const town = getTown(townId);
    return town && town.name ? town.name : `Town #${townId}`;
  }

  function randomInt(min, max) {
    return Math.floor(Math.random() * (max - min + 1)) + min;
  }

  async function humanDelay(reason) {
    const delayMs = randomInt(HUMAN_DELAY_MIN_MS, HUMAN_DELAY_MAX_MS);
    debugLog("human delay", { reason, delayMs });
    await sleep(delayMs);
  }

  function sleep(ms) {
    return new Promise((resolve) => window.setTimeout(resolve, ms));
  }

  function nowServerMs() {
    return state.bindings.Timestamp.now() * 1000;
  }

  function computeServerClockOffsetMs() {
    const serverTime = document.querySelector(SERVER_TIME_SELECTOR);
    if (!serverTime || !serverTime.textContent) {
      return 0;
    }
    const match = serverTime.textContent.trim().match(/(\d{2}):(\d{2}):(\d{2})\s+(\d{2})\/(\d{2})\/(\d{4})/);
    if (!match) {
      return 0;
    }
    const [, hour, minute, second, day, month, year] = match;
    const wallUtc = Date.UTC(
      Number(year),
      Number(month) - 1,
      Number(day),
      Number(hour),
      Number(minute),
      Number(second)
    );
    return wallUtc - nowServerMs();
  }

  function formatServerDateTime(epochMs) {
    if (!Number.isFinite(epochMs)) {
      return "Not set";
    }
    const shifted = new Date(epochMs + state.serverClockOffsetMs);
    const year = shifted.getUTCFullYear();
    const month = pad2(shifted.getUTCMonth() + 1);
    const day = pad2(shifted.getUTCDate());
    const hour = pad2(shifted.getUTCHours());
    const minute = pad2(shifted.getUTCMinutes());
    const second = pad2(shifted.getUTCSeconds());
    return `${year}-${month}-${day} ${hour}:${minute}:${second}`;
  }

  function parseServerDateTimeInput(value) {
    if (!value || !value.trim()) {
      return null;
    }
    const trimmed = value.trim();
    let match = trimmed.match(/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})(?::(\d{2}))?$/);
    if (match) {
      const [, year, month, day, hour, minute, second = "00"] = match;
      return Date.UTC(
        Number(year),
        Number(month) - 1,
        Number(day),
        Number(hour),
        Number(minute),
        Number(second)
      ) - state.serverClockOffsetMs;
    }
    match = trimmed.match(/^(\d{2})\/(\d{2})\/(\d{4})[ T](\d{2}):(\d{2})(?::(\d{2}))?$/);
    if (match) {
      const [, day, month, year, hour, minute, second = "00"] = match;
      return Date.UTC(
        Number(year),
        Number(month) - 1,
        Number(day),
        Number(hour),
        Number(minute),
        Number(second)
      ) - state.serverClockOffsetMs;
    }
    return null;
  }

  function pad2(value) {
    return String(value).padStart(2, "0");
  }

  function snapshotOutgoingMovements() {
    const toolbarMovements = getToolbarMovementElements().map(describeToolbarMovement).filter(Boolean);
    const modelMovements = getMovementModels().map(describeMovementModel).filter(Boolean);
    const movements = mergeMovementSnapshots(toolbarMovements, modelMovements);
    return {
      commandIds: new Set(movements.map((movement) => movement.commandId).filter(Boolean)),
      sourceRefs: new Set(movements.map((movement) => movement.sourceRef).filter(Boolean)),
      movements,
    };
  }

  function scoreMovementCandidate(movement, context) {
    if (!movement || !isResolvableAttackMovement(movement)) {
      return 0;
    }
    let score = 0;
    if (movement.commandId && !context.beforeSnapshot.commandIds.has(movement.commandId)) {
      score += 8;
    }
    if (movement.targetTownId === context.targetTownId) {
      score += 8;
    }
    if (movement.homeTownId === context.sourceTownId) {
      score += 8;
    }
    if (Array.isArray(movement.townIds)) {
      if (context.targetTownId && movement.townIds.includes(context.targetTownId)) {
        score += 4;
      }
      if (context.sourceTownId && movement.townIds.includes(context.sourceTownId)) {
        score += 4;
      }
    }
    if (movement.targetTownId === null) {
      score += 1;
    }
    if (movement.homeTownId === null) {
      score += 1;
    }
    score += 4;
    if (movement.startedAtMs) {
      const delta = Math.abs(movement.startedAtMs - context.sentAt);
      if (delta <= 15000) {
        score += 8;
      } else if (delta <= 60000) {
        score += 4;
      } else if (delta <= 180000) {
        score += 2;
      } else {
        score -= 4;
      }
    }
    return score;
  }

  function getTravelDurationMs(sentAt, arrivalAt, fallbackMs) {
    if (Number.isFinite(fallbackMs) && fallbackMs > 0) {
      return fallbackMs;
    }
    if (Number.isFinite(sentAt) && Number.isFinite(arrivalAt) && arrivalAt > sentAt) {
      return arrivalAt - sentAt;
    }
    return null;
  }

  function isShortRangePressureRoute(travelDurationMs) {
    return Number.isFinite(travelDurationMs) && travelDurationMs <= CANCELLATION_LOCK_MS;
  }

  function getAttackIntervalMs(travelDurationMs) {
    return isShortRangePressureRoute(travelDurationMs)
      ? SHORT_RANGE_ATTACK_INTERVAL_MS
      : ATTACK_INTERVAL_MS;
  }

  function getCancelBehaviorForWave(travelDurationMs, waveSequence) {
    if (!isShortRangePressureRoute(travelDurationMs)) {
      return "land";
    }
    void waveSequence;
    return "cancel";
  }

  function createPendingCommandEntry(runtime, profile, sent, nextCycleAt) {
    const waveSequence = clamp(toInteger(runtime.waveSequence, 0) + 1, 1, 999999);
    const travelDurationMs = getTravelDurationMs(sent.sentAt, sent.arrivalAt, sent.travelDurationMs);
    const cancelBehavior = getCancelBehaviorForWave(travelDurationMs, waveSequence);
    runtime.waveSequence = waveSequence;
    return {
      commandId: sent.commandId,
      sourceTownId: profile.sourceTownId,
      targetTownId: profile.targetTownId,
      sentAt: sent.sentAt,
      nextCycleAt,
      plannedCancelAt: computePlannedCancelAt(sent.sentAt, sent.arrivalAt, travelDurationMs, cancelBehavior),
      arrivalAt: sent.arrivalAt,
      travelDurationMs,
      expectedType: sent.expectedType,
      cancelBehavior,
    };
  }

  function computePlannedCancelAt(sentAt, arrivalAt, travelDurationMs, cancelBehavior) {
    if (cancelBehavior === "land") {
      return null;
    }
    if (!Number.isFinite(sentAt) || !Number.isFinite(arrivalAt) || !Number.isFinite(travelDurationMs)) {
      return null;
    }
    if (isShortRangePressureRoute(travelDurationMs)) {
      const earliestCancelAt = Math.max(sentAt, arrivalAt - SHORT_RANGE_CANCEL_EARLIEST_MS);
      const latestCancelAt = Math.max(earliestCancelAt, arrivalAt - SHORT_RANGE_CANCEL_LATEST_MS);
      return randomTimeBetween(earliestCancelAt, latestCancelAt);
    }
    const latestCancelAt = arrivalAt - CANCELLATION_LOCK_MS;
    const baseCancelAt = latestCancelAt - CANCELLATION_SAFETY_MS;
    return clampTimeWithJitter(baseCancelAt, sentAt, latestCancelAt);
  }

  function computeNextSendAt(referenceAt, travelDurationMs) {
    if (!Number.isFinite(referenceAt)) {
      return null;
    }
    const intervalMs = getAttackIntervalMs(travelDurationMs);
    return Math.max(referenceAt, referenceAt + intervalMs + randomSignedJitterMs());
  }

  function randomSignedJitterMs() {
    return randomInt(-EXECUTION_JITTER_MS, EXECUTION_JITTER_MS);
  }

  function randomTimeBetween(minAt, maxAt) {
    if (!Number.isFinite(minAt) || !Number.isFinite(maxAt)) {
      return null;
    }
    if (maxAt <= minAt) {
      return minAt;
    }
    return randomInt(Math.trunc(minAt), Math.trunc(maxAt));
  }

  function clampTimeWithJitter(baseAt, minAt, maxAt) {
    if (!Number.isFinite(baseAt)) {
      return null;
    }
    const jitteredAt = baseAt + randomSignedJitterMs();
    const safeMinAt = Number.isFinite(minAt) ? minAt : jitteredAt;
    const safeMaxAt = Number.isFinite(maxAt) ? maxAt : jitteredAt;
    return Math.min(Math.max(jitteredAt, safeMinAt), safeMaxAt);
  }

  function buildSendLogMessage(sent, entry) {
    const parts = [
      `Sent attack ${sent.commandId || "pending"} at ${formatServerDateTime(sent.sentAt)}.`,
    ];
    if (sent.untracked) {
      parts.push("The command could not be resolved from Grepolis models, so this wave will remain untracked.");
      return parts.join(" ");
    }
    if (Number.isFinite(sent.arrivalAt)) {
      parts.push(`Arrival ${formatServerDateTime(sent.arrivalAt)}.`);
    }
    if (entry && entry.cancelBehavior === "land") {
      parts.push(
        isShortRangePressureRoute(entry.travelDurationMs)
          ? `This short-range wave will be allowed to land to keep pressure inside ${Math.round(PRESSURE_WINDOW_MS / 60000)} minutes.`
          : "This long-range wave will be allowed to land because pressure under 5 minutes cannot be maintained by cancellation."
      );
    } else if (entry && Number.isFinite(entry.plannedCancelAt)) {
      parts.push(`Cancel planned for ${formatServerDateTime(entry.plannedCancelAt)}.`);
    } else {
      parts.push("No cancellation was scheduled for this wave.");
    }
    return parts.join(" ");
  }

  function resolvePendingCommandEntry(entry) {
    const trackedCommandIds = new Set(
      state.data.runtime.pendingCommands
        .filter((candidate) => candidate !== entry)
        .map((candidate) => candidate.commandId)
        .filter(Boolean)
    );
    const candidates = snapshotOutgoingMovements().movements
      .filter((movement) => isResolvableAttackMovement(movement, entry.expectedType))
      .filter((movement) => !movement.commandId || !trackedCommandIds.has(movement.commandId))
      .map((movement) => ({
        movement,
        score: scoreTrackedEntryCandidate(entry, movement),
      }))
      .filter((candidate) => candidate.score > 0)
      .sort((left, right) => right.score - left.score);
    return candidates.length ? candidates[0].movement : null;
  }

  function scoreTrackedEntryCandidate(entry, movement) {
    if (!isResolvableAttackMovement(movement, entry && entry.expectedType)) {
      return 0;
    }
    let score = 0;
    if (movement.targetTownId === entry.targetTownId) {
      score += 8;
    }
    if (movement.homeTownId === entry.sourceTownId) {
      score += 8;
    }
    if (Array.isArray(movement.townIds)) {
      if (entry.targetTownId && movement.townIds.includes(entry.targetTownId)) {
        score += 4;
      }
      if (entry.sourceTownId && movement.townIds.includes(entry.sourceTownId)) {
        score += 4;
      }
    }
    score += 4;
    if (movement.startedAtMs && entry.sentAt) {
      const delta = Math.abs(movement.startedAtMs - entry.sentAt);
      if (delta <= 15000) {
        score += 8;
      } else if (delta <= 60000) {
        score += 4;
      } else if (delta <= 180000) {
        score += 1;
      } else {
        score -= 4;
      }
    }
    if (movement.arrivalAtMs && entry.arrivalAt) {
      const delta = Math.abs(movement.arrivalAtMs - entry.arrivalAt);
      if (delta <= 15000) {
        score += 6;
      } else if (delta <= 60000) {
        score += 3;
      } else {
        score -= 2;
      }
    }
    return score;
  }

  function normalizeDurationCandidate(value) {
    if (typeof value === "number" && Number.isFinite(value) && value > 0) {
      return value > 100000 ? value : value * 1000;
    }
    if (typeof value !== "string") {
      return null;
    }
    const trimmed = value.trim();
    if (!trimmed) {
      return null;
    }
    let match = trimmed.match(/^(\d{1,2}):(\d{2}):(\d{2})$/);
    if (match) {
      return ((Number(match[1]) * 60 + Number(match[2])) * 60 + Number(match[3])) * 1000;
    }
    match = trimmed.match(/^(\d+)\s*s$/i);
    if (match) {
      return Number(match[1]) * 1000;
    }
    return null;
  }

  function readNestedValue(root, paths) {
    for (const path of paths) {
      let current = root;
      let found = true;
      for (const key of path) {
        if (!current || typeof current !== "object" || !(key in current)) {
          found = false;
          break;
        }
        current = current[key];
      }
      if (found && current !== undefined && current !== null) {
        return current;
      }
    }
    return null;
  }

  function visitObject(value, visitor, path) {
    if (!value || typeof value !== "object") {
      return;
    }
    visitor(value, path || []);
    if (Array.isArray(value)) {
      value.forEach((entry, index) => visitObject(entry, visitor, (path || []).concat(String(index))));
      return;
    }
    Object.keys(value).forEach((key) => visitObject(value[key], visitor, (path || []).concat(key)));
  }

  function getToolbarMovementElements() {
    const selectors = [
      "#toolbar_activity_commands_list .command",
      "#toolbar_activity_commands_list [id^='movement']",
      "#toolbar_activity_commands_list .js-dropdown-item-list > div",
    ];
    const elements = [];
    const seen = new Set();
    selectors.forEach((selector) => {
      document.querySelectorAll(selector).forEach((element) => {
        if (!(element instanceof HTMLElement) || seen.has(element)) {
          return;
        }
        seen.add(element);
        elements.push(element);
      });
    });
    return elements;
  }

  function describeToolbarMovement(element) {
    const commandId = extractToolbarCommandId(element);
    if (!commandId) {
      return null;
    }
    const townIds = extractToolbarTownIds(element);
    const type = extractToolbarMovementType(element);
    const arrivalAtSec = toNullableInteger(element.dataset && element.dataset.timestamp);
    const startedAtSec = toNullableInteger(element.dataset && element.dataset.starttime);
    return {
      model: null,
      sourceRef: element.id || element,
      commandId,
      targetTownId: townIds.length > 1 ? townIds[townIds.length - 1] : null,
      homeTownId: townIds.length > 0 ? townIds[0] : null,
      townIds,
      type,
      incoming: false,
      startedAtMs: startedAtSec && startedAtSec > 0 ? startedAtSec * 1000 : null,
      arrivalAtMs: arrivalAtSec && arrivalAtSec > 0 ? arrivalAtSec * 1000 : null,
    };
  }

  function extractToolbarCommandId(element) {
    const movementId = extractToolbarMovementId(element);
    const modelCommandId = movementId ? getCommandIdForMovementId(movementId) : null;
    if (modelCommandId) {
      return modelCommandId;
    }

    const candidates = [];
    if (element.id) {
      candidates.push(element.id);
    }
    if (element.dataset) {
      candidates.push(element.dataset.commandId, element.dataset.command_id, element.dataset.movementId, element.dataset.id);
    }
    const cancelControl = element.querySelector(".cancel, [data-command-id], [data-command_id]");
    if (cancelControl instanceof HTMLElement) {
      if (cancelControl.dataset) {
        candidates.push(cancelControl.dataset.commandId, cancelControl.dataset.command_id, cancelControl.dataset.id);
      }
      if (cancelControl.id) {
        candidates.push(cancelControl.id);
      }
      candidates.push(cancelControl.getAttribute("href"), cancelControl.getAttribute("onclick"));
    }
    for (const candidate of candidates) {
      const commandId = extractIntegerToken(candidate);
      if (commandId) {
        return commandId;
      }
    }
    return null;
  }

  function extractToolbarMovementId(element) {
    const candidates = [];
    if (element.id) {
      candidates.push(element.id);
    }
    if (element.dataset) {
      candidates.push(element.dataset.movementId, element.dataset.movement_id, element.dataset.id);
    }
    for (const candidate of candidates) {
      const movementId = extractIntegerToken(candidate);
      if (movementId) {
        return movementId;
      }
    }
    return null;
  }

  function getCommandIdForMovementId(movementId) {
    const movementModel = findMovementModelById(movementId);
    if (!movementModel) {
      return null;
    }
    if (typeof movementModel.getCommandId === "function") {
      return toNullableInteger(movementModel.getCommandId());
    }
    return toNullableInteger(
      movementModel.command_id !== undefined ? movementModel.command_id :
      movementModel.commandId !== undefined ? movementModel.commandId :
      movementModel.attributes && movementModel.attributes.command_id !== undefined ? movementModel.attributes.command_id :
      movementModel.attributes && movementModel.attributes.commandId !== undefined ? movementModel.attributes.commandId :
      null
    );
  }

  function findMovementModelById(movementId) {
    if (!movementId || !state.bindings || !state.bindings.window) {
      return null;
    }
    const movementCollections = state.bindings.window.MM && typeof state.bindings.window.MM.getCollections === "function"
      ? state.bindings.window.MM.getCollections()
      : null;
    if (!movementCollections || typeof movementCollections !== "object") {
      return null;
    }
    for (const [key, collections] of Object.entries(movementCollections)) {
      if (!key || !key.toLowerCase().startsWith("movements")) {
        continue;
      }
      if (!Array.isArray(collections)) {
        continue;
      }
      for (const collection of collections) {
        if (!collection || !Array.isArray(collection.models)) {
          continue;
        }
        const directMatch = collection.models.find((model) => {
          if (!model) {
            return false;
          }
          const candidateId = toNullableInteger(
            model.id !== undefined ? model.id :
            model.attributes && model.attributes.id !== undefined ? model.attributes.id :
            null
          );
          return candidateId === movementId;
        });
        if (directMatch) {
          return directMatch;
        }
      }
    }
    return null;
  }

  function findMovementModelByCommandId(commandId) {
    if (!commandId || !state.bindings || !state.bindings.window) {
      return null;
    }
    const movementCollections = state.bindings.window.MM && typeof state.bindings.window.MM.getCollections === "function"
      ? state.bindings.window.MM.getCollections()
      : null;
    if (!movementCollections || typeof movementCollections !== "object") {
      return null;
    }
    for (const [key, collections] of Object.entries(movementCollections)) {
      if (!key || !key.toLowerCase().startsWith("movements")) {
        continue;
      }
      if (!Array.isArray(collections)) {
        continue;
      }
      for (const collection of collections) {
        if (!collection || !Array.isArray(collection.models)) {
          continue;
        }
        const directMatch = collection.models.find((model) => {
          if (!model) {
            return false;
          }
          const candidateCommandId = toNullableInteger(
            typeof model.getCommandId === "function"
              ? model.getCommandId()
              : model.command_id !== undefined ? model.command_id :
              model.commandId !== undefined ? model.commandId :
              model.attributes && model.attributes.command_id !== undefined ? model.attributes.command_id :
              model.attributes && model.attributes.commandId !== undefined ? model.attributes.commandId :
              null
          );
          return candidateCommandId === commandId;
        });
        if (directMatch) {
          return directMatch;
        }
      }
    }
    return null;
  }

  function extractToolbarTownIds(element) {
    const townIds = [];
    element.querySelectorAll(".gp_town_link").forEach((link) => {
      const href = link.getAttribute("href") || "";
      const hash = href.includes("#") ? href.split("#").pop() : "";
      if (!hash) {
        return;
      }
      try {
        const decoded = JSON.parse(atob(hash));
        const townId = toNullableInteger(decoded && decoded.id);
        if (townId && !townIds.includes(townId)) {
          townIds.push(townId);
        }
      } catch (_error) {
        // Ignore malformed hashes in toolbar links.
      }
    });
    return townIds;
  }

  function extractToolbarMovementType(element) {
    const imageIcon = element.querySelector("img");
    const spriteIcon = element.querySelector(".icon");
    const sources = [
      element.className,
      imageIcon && imageIcon.className,
      imageIcon && imageIcon.getAttribute("src"),
      spriteIcon && spriteIcon.className,
    ];
    for (const source of sources) {
      if (typeof source !== "string") {
        continue;
      }
      const lowered = source.toLowerCase();
      if (lowered.includes("attack")) {
        return "attack";
      }
      if (lowered.includes("support")) {
        return "support";
      }
      if (lowered.includes("return")) {
        return "return";
      }
    }
    return "";
  }

  function extractIntegerToken(value) {
    if (typeof value === "number" && Number.isFinite(value)) {
      return Math.trunc(value);
    }
    if (typeof value !== "string") {
      return null;
    }
    const match = value.match(/(\d{3,})/);
    return match ? toNullableInteger(match[1]) : null;
  }

  function isAttackMovementType(type) {
    return typeof type === "string" && type.toLowerCase().includes("attack");
  }

  function isResolvableAttackMovement(movement, fallbackType) {
    if (!movement) {
      return false;
    }
    const effectiveType = typeof movement.type === "string" && movement.type
      ? movement.type
      : fallbackType || "";
    if (!isAttackMovementType(effectiveType)) {
      return false;
    }
    return true;
  }

  function findMovementByCommandId(commandId) {
    const toolbarMovement = getToolbarMovementElements()
      .map(describeToolbarMovement)
      .find((movement) => movement && movement.commandId === commandId);
    if (toolbarMovement) {
      return toolbarMovement;
    }
    const modelMovement = findMovementModelByCommandId(commandId);
    if (modelMovement) {
      return describeMovementModel(modelMovement);
    }
    return snapshotOutgoingMovements().movements.find((movement) => movement.commandId === commandId) || null;
  }

  function describeMovementModel(model) {
    if (!model) {
      return null;
    }
    const attrs = model.attributes || model;
    const type = String(
      typeof model.getType === "function"
        ? model.getType()
        : attrs.type || attrs.command_type || ""
    ).toLowerCase();
    return {
      model,
      sourceRef: attrs.id || model.cid || model,
      commandId: toNullableInteger(
        typeof model.getCommandId === "function"
          ? model.getCommandId()
          : attrs.command_id !== undefined ? attrs.command_id :
          attrs.commandId !== undefined ? attrs.commandId :
          null
      ),
      targetTownId: toNullableInteger(
        typeof model.getTargetTownId === "function"
          ? model.getTargetTownId()
          : attrs.target_town_id !== undefined ? attrs.target_town_id :
          attrs.targetTownId !== undefined ? attrs.targetTownId :
          null
      ),
      homeTownId: toNullableInteger(
        typeof model.getHomeTownId === "function"
          ? model.getHomeTownId()
          : attrs.home_town_id !== undefined ? attrs.home_town_id :
          attrs.homeTownId !== undefined ? attrs.homeTownId :
          null
      ),
      townIds: [],
      type,
      incoming: typeof model.isIncomingMovement === "function" ? Boolean(model.isIncomingMovement()) : false,
      startedAtMs: Number.isFinite(attrs.started_at) ? attrs.started_at * 1000 : null,
      arrivalAtMs: Number.isFinite(attrs.arrival_at) ? attrs.arrival_at * 1000 : null,
    };
  }

  function getMovementModels() {
    if (!state.bindings || !state.bindings.window) {
      return [];
    }
    const movementCollections = state.bindings.window.MM && typeof state.bindings.window.MM.getCollections === "function"
      ? state.bindings.window.MM.getCollections()
      : null;
    if (!movementCollections || typeof movementCollections !== "object") {
      return [];
    }
    const models = [];
    for (const [key, collections] of Object.entries(movementCollections)) {
      if (!key || !key.toLowerCase().startsWith("movements")) {
        continue;
      }
      if (!Array.isArray(collections)) {
        continue;
      }
      collections.forEach((collection) => {
        if (!collection || !Array.isArray(collection.models)) {
          return;
        }
        collection.models.forEach((model) => {
          if (model) {
            models.push(model);
          }
        });
      });
    }
    return models;
  }

  function mergeMovementSnapshots() {
    const merged = new Map();
    Array.from(arguments).forEach((bucket) => {
      if (!Array.isArray(bucket)) {
        return;
      }
      bucket.forEach((movement) => {
        if (!movement) {
          return;
        }
        const key = movement.commandId ? `command:${movement.commandId}` : `ref:${String(movement.sourceRef)}`;
        const existing = merged.get(key);
        if (!existing || countMovementSignals(movement) > countMovementSignals(existing)) {
          merged.set(key, movement);
        }
      });
    });
    return Array.from(merged.values());
  }

  function countMovementSignals(movement) {
    if (!movement) {
      return 0;
    }
    let score = 0;
    if (movement.commandId) {
      score += 2;
    }
    if (movement.targetTownId) {
      score += 2;
    }
    if (movement.homeTownId) {
      score += 2;
    }
    if (movement.startedAtMs) {
      score += 1;
    }
    if (movement.arrivalAtMs) {
      score += 1;
    }
    if (movement.type) {
      score += 1;
    }
    if (Array.isArray(movement.townIds) && movement.townIds.length) {
      score += 1;
    }
    return score;
  }

  function removePendingCommand(commandId) {
    state.data.runtime.pendingCommands = state.data.runtime.pendingCommands.filter((entry) => entry.commandId !== commandId);
  }

  function appendLog(level, message) {
    const entry = normalizeLogEntry({
      at: nowServerMs(),
      level,
      message: String(message || ""),
    });
    state.data.recentLog.push(entry);
    if (state.data.recentLog.length > LOG_LIMIT) {
      state.data.recentLog = state.data.recentLog.slice(-LOG_LIMIT);
    }
    saveState();
    renderLog();
    renderStatus();
  }

  function showBanner(message) {
    void message;
  }

  function hideBanner() {
    return;
  }

  function safeParseJson(value) {
    if (!value || typeof value !== "string") {
      return null;
    }
    try {
      return JSON.parse(value);
    } catch (_error) {
      return null;
    }
  }

  function toInteger(value, fallback) {
    if (value === "" || value === null || value === undefined) {
      return fallback;
    }
    const numeric = Number(value);
    if (!Number.isFinite(numeric)) {
      return fallback;
    }
    return Math.trunc(numeric);
  }

  function toNullableInteger(value) {
    if (value === "" || value === null || value === undefined) {
      return null;
    }
    const numeric = Number(value);
    if (!Number.isFinite(numeric)) {
      return null;
    }
    return Math.trunc(numeric);
  }

  function clamp(value, min, max) {
    if (!Number.isFinite(value)) {
      return min;
    }
    return Math.min(Math.max(value, min), max);
  }

  function normalizeError(error) {
    if (!error) {
      return "Unknown error.";
    }
    if (typeof error === "string") {
      return error;
    }
    if (error instanceof Error && error.message) {
      return error.message;
    }
    if (typeof error.message === "string" && error.message) {
      return error.message;
    }
    if (typeof error.error === "string" && error.error) {
      return error.error;
    }
    try {
      return JSON.stringify(error);
    } catch (_stringifyError) {
      return String(error);
    }
  }

  function isMissingCommandCancellationError(reason) {
    if (typeof reason !== "string" || !reason.trim()) {
      return false;
    }
    const normalized = reason.toLowerCase();
    return (
      normalized.includes("la orden no existe") ||
      normalized.includes("command does not exist") ||
      normalized.includes("command no longer exists") ||
      normalized.includes("did not disappear after cancellation")
    );
  }

  function isRecoverableRuntimeError(reason) {
    if (typeof reason !== "string" || !reason.trim()) {
      return false;
    }
    const normalized = reason.toLowerCase();
    if (
      normalized.includes("lock lost") ||
      normalized.includes("another grepolis tab") ||
      normalized.includes("source town is invalid") ||
      normalized.includes("could not switch to source town") ||
      normalized.includes("town switch did not complete")
    ) {
      return false;
    }
    return (
      normalized.includes("attack dispatch could not be confirmed") ||
      normalized.includes("could not resolve the created command from the command toolbar") ||
      normalized.includes("request timed out") ||
      normalized.includes("timed out waiting for callback")
    );
  }

  function extractAjaxMessage(response) {
    if (!response || typeof response !== "object") {
      return "";
    }
    const directCandidates = [
      response.message,
      response.error,
      response.msg,
      response.error_msg,
    ];
    for (const candidate of directCandidates) {
      if (typeof candidate === "string" && candidate.trim()) {
        return candidate.trim();
      }
    }
    if (Array.isArray(response.notifications)) {
      for (const notification of response.notifications) {
        const message = notification && (notification.message || notification.text || notification.error);
        if (typeof message === "string" && message.trim()) {
          return message.trim();
        }
      }
    }
    const nestedMessage = findNestedAjaxMessage(response);
    if (nestedMessage) {
      return nestedMessage;
    }
    if (response.json && typeof response.json === "object") {
      return extractAjaxMessage(response.json);
    }
    return "";
  }

  function findNestedAjaxMessage(response) {
    let found = "";
    visitObject(response, (entry) => {
      if (found || !entry || typeof entry !== "object") {
        return;
      }
      const candidates = [
        entry.message,
        entry.error,
        entry.msg,
        entry.error_msg,
        entry.text,
        entry.description,
        entry.localized_message,
      ];
      for (const candidate of candidates) {
        if (typeof candidate === "string" && candidate.trim()) {
          found = candidate.trim();
          return;
        }
      }
    });
    return found;
  }

  function ajaxGet(controller, action, data) {
    return ajax("ajaxGet", controller, action, data);
  }

  function ajaxPost(controller, action, data) {
    return ajax("ajaxPost", controller, action, data);
  }

  function ajax(method, controller, action, data) {
    return new Promise((resolve, reject) => {
      const fn = state.bindings.gpAjax && state.bindings.gpAjax[method];
      if (typeof fn !== "function") {
        reject(new Error(`gpAjax.${method} is not available.`));
        return;
      }

      let settled = false;
      const timeoutId = window.setTimeout(() => {
        if (settled) {
          return;
        }
        settled = true;
        reject(new Error(`Request timed out: ${controller}/${action}`));
      }, AJAX_TIMEOUT_MS);

      const finish = (error, response) => {
        if (settled) {
          return;
        }
        settled = true;
        window.clearTimeout(timeoutId);
        if (error) {
          const normalized = normalizeAjaxFailure(error, response, controller, action);
          reject(normalized);
          return;
        }
        if (hasAjaxFailure(response)) {
          reject(normalizeAjaxFailure(null, response, controller, action));
          return;
        }
        resolve(response);
      };

      try {
        const request = fn.call(state.bindings.gpAjax, controller, action, data || {}, false, function (response) {
          finish(null, response);
        });
        if (request && typeof request.then === "function") {
          request.then(
            (response) => finish(null, response),
            (error) => finish(error)
          );
          return;
        }
        if (request && typeof request.done === "function") {
          request.done((response) => finish(null, response));
          if (typeof request.fail === "function") {
            request.fail((xhr, _textStatus, errorThrown) => {
              finish(errorThrown || xhr, xhr);
            });
          }
        }
      } catch (error) {
        finish(error);
      }
    });
  }

  function hasAjaxFailure(response) {
    return Boolean(
      response && (
        response.success === false ||
        (response.json && response.json.success === false)
      )
    );
  }

  function normalizeAjaxFailure(error, response, controller, action) {
    const responseMessage = extractAjaxMessage(response);
    const errorMessage = normalizeError(error);
    const message = responseMessage || errorMessage || `Request failed: ${controller}/${action}`;
    return message instanceof Error ? message : new Error(String(message));
  }

  function getStoredLock() {
    const parsed = safeParseJson(localStorage.getItem(STORAGE_LOCK_KEY));
    if (!parsed || typeof parsed !== "object") {
      return null;
    }
    if (typeof parsed.ownerId !== "string" || !parsed.ownerId) {
      return null;
    }
    return {
      ownerId: parsed.ownerId,
      updatedAt: toInteger(parsed.updatedAt, 0),
      expiresAt: toInteger(parsed.expiresAt, 0),
    };
  }

  function isLockExpired(lock) {
    return !lock || !Number.isFinite(lock.expiresAt) || lock.expiresAt <= Date.now();
  }

  function acquireTabLock() {
    const current = getStoredLock();
    if (current && !isLockExpired(current) && current.ownerId !== state.ownerId) {
      return false;
    }
    const now = Date.now();
    const next = {
      ownerId: state.ownerId,
      updatedAt: now,
      expiresAt: now + LOCK_TTL_MS,
    };
    localStorage.setItem(STORAGE_LOCK_KEY, JSON.stringify(next));
    const stored = getStoredLock();
    const acquired = Boolean(stored && stored.ownerId === state.ownerId && !isLockExpired(stored));
    if (acquired) {
      broadcast({ type: "lock-updated", ownerId: state.ownerId, lock: stored });
    }
    return acquired;
  }

  function refreshTabLock() {
    const current = getStoredLock();
    if (!current || isLockExpired(current) || current.ownerId !== state.ownerId) {
      return false;
    }
    const now = Date.now();
    const next = {
      ownerId: state.ownerId,
      updatedAt: now,
      expiresAt: now + LOCK_TTL_MS,
    };
    localStorage.setItem(STORAGE_LOCK_KEY, JSON.stringify(next));
    broadcast({ type: "lock-updated", ownerId: state.ownerId, lock: next });
    return true;
  }

  function releaseTabLock() {
    const current = getStoredLock();
    if (!current || current.ownerId !== state.ownerId) {
      return;
    }
    localStorage.removeItem(STORAGE_LOCK_KEY);
    broadcast({ type: "lock-released", ownerId: state.ownerId });
  }

  function handleBroadcastMessage(message) {
    if (!message || message.ownerId === state.ownerId) {
      return;
    }
    if ((message.type === "lock-updated" || message.type === "lock-released") && state.data.runtime.active) {
      const lock = getStoredLock();
      if (lock && !isLockExpired(lock) && lock.ownerId !== state.ownerId) {
        void stopWithAlert("Lock lost to another Grepolis tab.", {
          preservePending: true,
          cancelPending: false,
        });
        return;
      }
    }
    if (message.type === "lock-updated" || message.type === "lock-released") {
      renderStatus();
    }
  }

  function handleForeignLockChange(newValue) {
    const lock = newValue ? safeParseJson(newValue) : null;
    if (state.data.runtime.active && lock && lock.ownerId && lock.ownerId !== state.ownerId && !isLockExpired(lock)) {
      void stopWithAlert("Lock lost to another Grepolis tab.", {
        preservePending: true,
        cancelPending: false,
      });
      return;
    }
    renderStatus();
  }

  function broadcast(message) {
    if (!state.channel) {
      return;
    }
    try {
      state.channel.postMessage(message);
    } catch (_error) {
      // Ignore transient channel failures.
    }
  }
})();