HeroWars CombatTraining Helper

Hero Warsの「ギルド戦」に「戦闘訓練」を追加します。

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         HeroWars CombatTraining Helper
// @namespace    sora.tools
// @version      0.4.0-rc21
// @description  Adds Combat Training to Guild War in the game Hero Wars.
// @description:en    Adds Combat Training to Guild War in the game Hero Wars.
// @description:ru    Добавляет «Тренировочный бой» в «Войну Гильдий» в игре Hero Wars.
// @description:pt    Adiciona «Treinamento de combate» à «Guerra da Guilda» no jogo Hero Wars.
// @description:fr    Ajoute «Combat d'entraînement» à «Guerre de guildes» dans le jeu Hero Wars.
// @description:it    Aggiunge «Combattimento di addestramento» a «Guerra delle Gilde» nel gioco Hero Wars.
// @description:de    Fügt „Kampftraining“ zum „Gildenkrieg“ im Spiel Hero Wars hinzu.
// @description:es    Añade «Entrenamiento de combate» a «Guerra de Gremios» en el juego Hero Wars.
// @description:zh-CN    在 Hero Wars 游戏中为“公会战”添加“作战训练”。
// @description:zh-TW    在 Hero Wars 遊戲中為「公會戰」新增「對戰訓練」。
// @description:ja    Hero Warsの「ギルド戦」に「戦闘訓練」を追加します。
// @description:ko    Hero Wars 게임의 “길드 워”에 “전투 훈련”을 추가합니다.
// @description:pl    Dodaje funkcję „Trening walki” do „Wojny Gildii” w grze Hero Wars.
// @description:th    เพิ่ม “การฝึกซ้อมการต่อสู้” ให้กับ “สงครามกิลด์” ในเกม Hero Wars
// @author       sora
// @license      Copyright (c) 2026 sora. All rights reserved.
// @match        https://www.hero-wars.com/*
// @run-at       document-idle
// @grant        none
// ==/UserScript==

(() => {
  'use strict';

  const VERSION = '0.4.0-rc21';
  const HOST_ID = 'hw-combat-training-helper-host';
  const PATRON_HOST_ID = 'hw-patron-reference-host';
  const REFERENCE_REOPEN_HOST_ID = 'hw-defense-reference-reopen-host';
  const FLAG_SCAN_LIMIT = 900;
  const UI_STORAGE_KEY = 'sora.hwct.ui.v0.2';
  const REFRESH_MS = 800;
  const WAIT_STEP_MS = 80;
  const WAIT_TIMEOUT_MS = 6000;
  const UI_FONT_MIN = 8;
  const UI_FONT_MAX = 20;
  const UI_FONT_DEFAULT = 13;
  const MAIN_PANEL_DEFAULT_WIDTH = 330;
  const REF_PANEL_DEFAULT_WIDTH = 410;
  const MAIN_PANEL_MIN_WIDTH = 260;
  const REF_PANEL_MIN_WIDTH = 300;
  const PANEL_MIN_HEIGHT = 140;
  const PANEL_VIEWPORT_WIDTH_GUTTER = 16;

  function getPanelAvailableWidth() {
    return Math.max(1, window.innerWidth - PANEL_VIEWPORT_WIDTH_GUTTER);
  }

  function getResponsivePanelWidth(preferredWidth, defaultWidth, normalMinWidth) {
    const available = getPanelAvailableWidth();
    const preferred = Number.isFinite(Number(preferredWidth)) ? Number(preferredWidth) : defaultWidth;
    return Math.min(Math.max(normalMinWidth, preferred), available);
  }

  function getResponsivePanelMinWidth(normalMinWidth) {
    return Math.min(normalMinWidth, getPanelAvailableWidth());
  }

  const MODES = Object.freeze({
    MAX: 'max',
    MAX_CURR: 'maxCurr',
  });

  // Native Hero Wars localization keys confirmed across all 13 live game languages.
  // Use these for standalone game terms. Helper-specific prose stays in I18N,
  // because inserting translated nouns into sentences can break grammar.
  const NATIVE_UI_KEYS = Object.freeze({
    guildWar: 'UI_CROSS_CLAN_WAR_SELECT_MODE_CLAN_WAR',
    clashOfWorlds: 'UI_CROSS_CLAN_WAR_SELECT_MODE_CROSS_CLAN_WAR',
    combatTraining: 'UI_DEMO_BATTLE_BUTTON_TOOLTIP_TITLE',
    warFlag: 'UI_DIALOG_BANNER_STONE_INFO_BANNER',
    patterns: 'UI_DIALOG_TEAM_GATHER_TAB_BANNER_STONE',
    patronPet: 'UI_DIALOG_HERO_FAVOR_PET_TITLE',
    pet: 'UI_DIALOG_TEAM_GATHER_TAB_PET',
  });

  const NATIVE_I18N_KEYS = Object.freeze({
    combatTraining: 'combatTraining',
    flag: 'warFlag',
  });

  let nativeTranslateClassCache = null;

  function getNativeTranslateClassSafe() {
    if (
      nativeTranslateClassCache &&
      typeof nativeTranslateClassCache.translate === 'function'
    ) {
      return nativeTranslateClassCache;
    }

    const root = window.$haxe;
    if (!root || typeof root !== 'object') return null;

    const found = Object.values(root).find(
      value =>
        typeof value === 'function' &&
        value.j === 'com.progrestar.common.lang.Translate' &&
        typeof value.translate === 'function'
    ) ?? null;

    if (found) nativeTranslateClassCache = found;
    return found;
  }

  function nativeText(translationKey, fallback = '') {
    if (!translationKey) return String(fallback ?? '');
    try {
      const Translate = getNativeTranslateClassSafe();
      if (!Translate) return String(fallback ?? '');
      const value = Translate.translate(translationKey);
      if (value == null || String(value) === translationKey) {
        return String(fallback ?? '');
      }
      return String(value);
    } catch {
      return String(fallback ?? '');
    }
  }

  function nativeUiText(name, fallback = '') {
    return nativeText(NATIVE_UI_KEYS[name], fallback);
  }

  const I18N = Object.freeze({
    "en": Object.freeze({
      settings: "Settings",
      minimize: "Minimize",
      close: "Close",
      fontSize: "Font size",
      save: "Save",
      reset: "Reset",
      combatTraining: "Combat Training",
      defenseReference: "Defense Reference",
      defense: "Defense",
      currentDefense: "Current Defense",
      warFlagId: "War Flag {id}",
      noWarFlag: "No War Flag",
      flag: "War Flag",
      flagId: "War Flag {id}",
      patternSlotEmpty: "Pattern slot {slot}: empty",
      loadingPlayer: "Loading {player}…",
      loadingReference: "Loading reference…",
      pastSetup: "Past Setup",
      pastSetupInfo: "Past Setup information",
      pastSetupTip: "Shown when the same Hero lineup for the selected player is found in a past real battle log. Use it as a reference when assigning Patron Pets to this lineup.",
      lastSeen: "Last seen {date}",
      noMatchingBattleLog: "No matching battle log found.",
      noMainPet: "No main pet",
      mainPetId: "Main Pet {id}",
      heroId: "Hero {id}",
      heroDefeatedId: "Hero {id} · DEFEATED",
      defeatedUpper: "DEFEATED",
      patronId: "Patron Pet {id}",
      usedPatrons: "Used Patron Pets",
      usedPatronsInfo: "Used Patron Pets information",
      usedPatronsTip: "Shows Patron Pets this player used in completed battles during the current matchup, excluding the currently selected Hero lineup.",
      noPatronUse: "No Patron Pet use recorded yet.",
      usedPatronId: "Used Patron Pet {id}",
      referenceCouldNotBeLoaded: "Reference could not be loaded.",
      openDefenseReference: "Open Defense Reference",
      referenceClosedReopen: "Defense Reference closed. Use the ⚔ button to reopen it.",
      failedDisplayDefenseReference: "Failed to display Defense Reference.",
      couldNotLoadBattleLogReference: "Could not load battle-log reference. Combat Training is unaffected.",
      openGwAttackTarget: "Open a Guild War attack-target screen.",
      defenseEditorOpen: "Defense editor is open",
      ok: "OK",
      openCombatTraining: "Open Combat Training",
      stateEmpty: "Empty",
      stateDefeated: "Defeated",
      stateInBattle: "In battle",
      stateUnknown: "Unknown",
      referenceRestoredFor: "Reference restored for {defense}.",
      closeCurrentDefenseEditorFirst: "Close the current defense editor first.",
      closeCurrentDefenseEditorBeforeSelecting: "Close the current defense editor before selecting another defense.",
      preparingDefense: "Preparing defense {slot}…",
      openedDefenseMax: "Opened defense {slot} in MAX.",
      loadingGame: "Loading game…",
      assignedToYou: "Assigned to you",
      openDefense: "Open defense {slot}",
      errorWaitGameLoad: "Wait for the game to finish loading, then try again.",
      errorCloseCombatTraining: "Close Combat Training before opening another defense.",
      errorSlotNotCurrentBuilding: "That defense slot is not in the current building.",
      errorSlotNotAvailable: "That defense slot is not currently available for training.",
      errorSlotNoTeam: "That defense slot has no team.",
      errorTargetUser: "Could not resolve the enemy player.",
      errorMaxPattern: "Stopped because the MAX Pattern could not be resolved safely.",
      errorMaxTeamTimeout: "The MAX team was not ready in time.",
      errorStopped: "Stopped ({detail})",
    }),
    "ru": Object.freeze({
      settings: "Настройки",
      minimize: "Свернуть",
      close: "Закрыть",
      fontSize: "Размер шрифта",
      save: "Сохранить",
      reset: "Сбросить",
      combatTraining: "Тренировочный бой",
      defenseReference: "Данные защиты",
      defense: "Защита",
      currentDefense: "Текущая защита",
      warFlagId: "Боевой флаг {id}",
      noWarFlag: "Нет боевого флага",
      flag: "Боевой флаг",
      flagId: "Боевой флаг {id}",
      patternSlotEmpty: "Ячейка узора {slot}: пусто",
      loadingPlayer: "Загрузка {player}…",
      loadingReference: "Загрузка данных…",
      pastSetup: "Прошлая расстановка",
      pastSetupInfo: "Информация о прошлой расстановке",
      pastSetupTip: "Показывается, когда в журнале прошлого реального боя найдена та же расстановка героев выбранного игрока. Используйте её как справочную информацию при назначении питомцев-покровителей этой расстановке.",
      lastSeen: "Последний раз: {date}",
      noMatchingBattleLog: "Подходящий журнал боя не найден.",
      noMainPet: "Нет основного питомца",
      mainPetId: "Основной питомец {id}",
      heroId: "Герой {id}",
      heroDefeatedId: "Герой {id} · ПОБЕЖДЁН",
      defeatedUpper: "ПОБЕЖДЁН",
      patronId: "Питомец-покровитель {id}",
      usedPatrons: "Использованные питомцы-покровители",
      usedPatronsInfo: "Информация об использованных питомцах-покровителях",
      usedPatronsTip: "Показывает питомцев-покровителей, которых этот игрок использовал в завершённых боях текущего противостояния, кроме выбранной сейчас расстановки героев.",
      noPatronUse: "Использование питомцев-покровителей пока не зафиксировано.",
      usedPatronId: "Использованный питомец-покровитель {id}",
      referenceCouldNotBeLoaded: "Не удалось загрузить данные защиты.",
      openDefenseReference: "Открыть данные защиты",
      referenceClosedReopen: "Данные защиты закрыты. Нажмите кнопку ⚔, чтобы открыть их снова.",
      failedDisplayDefenseReference: "Не удалось отобразить данные защиты.",
      couldNotLoadBattleLogReference: "Не удалось загрузить данные из журнала боёв. Тренировочный бой продолжает работать.",
      openGwAttackTarget: "Откройте экран цели атаки в Войне Гильдий.",
      defenseEditorOpen: "Редактор защиты открыт",
      ok: "OK",
      openCombatTraining: "Открыть «Тренировочный бой»",
      stateEmpty: "Пусто",
      stateDefeated: "Побеждён",
      stateInBattle: "В бою",
      stateUnknown: "Неизвестно",
      referenceRestoredFor: "Данные для {defense} восстановлены.",
      closeCurrentDefenseEditorFirst: "Сначала закройте текущий редактор защиты.",
      closeCurrentDefenseEditorBeforeSelecting: "Закройте текущий редактор защиты перед выбором другой защиты.",
      preparingDefense: "Подготовка защиты {slot}…",
      openedDefenseMax: "Защита {slot} открыта в MAX.",
      loadingGame: "Загрузка игры…",
      assignedToYou: "Назначено вам",
      openDefense: "Открыть защиту {slot}",
      errorWaitGameLoad: "Дождитесь окончания загрузки игры и попробуйте снова.",
      errorCloseCombatTraining: "Закройте «Тренировочный бой» перед открытием другой защиты.",
      errorSlotNotCurrentBuilding: "Эта ячейка защиты находится не в текущем здании.",
      errorSlotNotAvailable: "Эта ячейка защиты сейчас недоступна для тренировки.",
      errorSlotNoTeam: "В этой ячейке защиты нет команды.",
      errorTargetUser: "Не удалось определить игрока противника.",
      errorMaxPattern: "Остановлено: не удалось безопасно определить MAX-узор.",
      errorMaxTeamTimeout: "MAX-команда не была готова вовремя.",
      errorStopped: "Остановлено ({detail})",
    }),
    "pt": Object.freeze({
      settings: "Configurações",
      minimize: "Minimizar",
      close: "Fechar",
      fontSize: "Tamanho da fonte",
      save: "Salvar",
      reset: "Redefinir",
      combatTraining: "Treinamento de combate",
      defenseReference: "Referência de Defesa",
      defense: "Defesa",
      currentDefense: "Defesa atual",
      warFlagId: "Bandeira de Guerra {id}",
      noWarFlag: "Sem Bandeira de Guerra",
      flag: "Bandeira de Guerra",
      flagId: "Bandeira de Guerra {id}",
      patternSlotEmpty: "Slot de emblema {slot}: vazio",
      loadingPlayer: "Carregando {player}…",
      loadingReference: "Carregando referência…",
      pastSetup: "Formação anterior",
      pastSetupInfo: "Informações da formação anterior",
      pastSetupTip: "Exibido quando a mesma formação de Heróis do jogador selecionado é encontrada em um registro de batalha real anterior. Use-a como referência ao atribuir Mascotes Patronos a esta formação.",
      lastSeen: "Visto por último {date}",
      noMatchingBattleLog: "Nenhum registro de batalha correspondente foi encontrado.",
      noMainPet: "Sem mascote principal",
      mainPetId: "Mascote principal {id}",
      heroId: "Herói {id}",
      heroDefeatedId: "Herói {id} · DERROTADO",
      defeatedUpper: "DERROTADO",
      patronId: "Mascote Patrono {id}",
      usedPatrons: "Mascotes Patronos usados",
      usedPatronsInfo: "Informações sobre Mascotes Patronos usados",
      usedPatronsTip: "Mostra os Mascotes Patronos usados por este jogador em batalhas concluídas no confronto atual, exceto a formação de Heróis selecionada no momento.",
      noPatronUse: "Nenhum uso de Mascote Patrono registrado ainda.",
      usedPatronId: "Mascote Patrono usado {id}",
      referenceCouldNotBeLoaded: "Não foi possível carregar a referência.",
      openDefenseReference: "Abrir Referência de Defesa",
      referenceClosedReopen: "A Referência de Defesa foi fechada. Use o botão ⚔ para reabri-la.",
      failedDisplayDefenseReference: "Não foi possível exibir a Referência de Defesa.",
      couldNotLoadBattleLogReference: "Não foi possível carregar a referência do registro de batalha. O Treinamento de combate não foi afetado.",
      openGwAttackTarget: "Abra uma tela de alvo de ataque da Guerra da Guilda.",
      defenseEditorOpen: "O editor de defesa está aberto",
      ok: "OK",
      openCombatTraining: "Abrir Treinamento de combate",
      stateEmpty: "Vazio",
      stateDefeated: "Derrotado",
      stateInBattle: "Em batalha",
      stateUnknown: "Desconhecido",
      referenceRestoredFor: "Referência restaurada para {defense}.",
      closeCurrentDefenseEditorFirst: "Feche primeiro o editor de defesa atual.",
      closeCurrentDefenseEditorBeforeSelecting: "Feche o editor de defesa atual antes de selecionar outra defesa.",
      preparingDefense: "Preparando defesa {slot}…",
      openedDefenseMax: "Defesa {slot} aberta em MAX.",
      loadingGame: "Carregando jogo…",
      assignedToYou: "Atribuído a você",
      openDefense: "Abrir defesa {slot}",
      errorWaitGameLoad: "Espere o jogo terminar de carregar e tente novamente.",
      errorCloseCombatTraining: "Feche o Treinamento de combate antes de abrir outra defesa.",
      errorSlotNotCurrentBuilding: "Esse slot de defesa não está no edifício atual.",
      errorSlotNotAvailable: "Esse slot de defesa não está disponível para treino no momento.",
      errorSlotNoTeam: "Esse slot de defesa não tem equipe.",
      errorTargetUser: "Não foi possível identificar o jogador inimigo.",
      errorMaxPattern: "Interrompido porque não foi possível determinar com segurança o emblema MAX.",
      errorMaxTeamTimeout: "A equipe MAX não ficou pronta a tempo.",
      errorStopped: "Interrompido ({detail})",
    }),
    "fr": Object.freeze({
      settings: "Paramètres",
      minimize: "Réduire",
      close: "Fermer",
      fontSize: "Taille du texte",
      save: "Enregistrer",
      reset: "Réinitialiser",
      combatTraining: "Combat d'entraînement",
      defenseReference: "Référence de défense",
      defense: "Défense",
      currentDefense: "Défense actuelle",
      warFlagId: "Drapeau de guerre {id}",
      noWarFlag: "Aucun drapeau de guerre",
      flag: "Drapeau de guerre",
      flagId: "Drapeau de guerre {id}",
      patternSlotEmpty: "Emplacement de motif {slot} : vide",
      loadingPlayer: "Chargement de {player}…",
      loadingReference: "Chargement de la référence…",
      pastSetup: "Composition précédente",
      pastSetupInfo: "Informations sur la composition précédente",
      pastSetupTip: "Affiché lorsque la même composition de Héros du joueur sélectionné est trouvée dans le journal d’un combat réel précédent. Utilisez-la comme référence lorsque vous attribuez des familiers patrons à cette composition.",
      lastSeen: "Vu pour la dernière fois {date}",
      noMatchingBattleLog: "Aucun journal de combat correspondant trouvé.",
      noMainPet: "Aucun familier principal",
      mainPetId: "Familier principal {id}",
      heroId: "Héros {id}",
      heroDefeatedId: "Héros {id} · VAINCU",
      defeatedUpper: "VAINCU",
      patronId: "Familier patron {id}",
      usedPatrons: "Familiers patrons utilisés",
      usedPatronsInfo: "Informations sur les familiers patrons utilisés",
      usedPatronsTip: "Affiche les familiers patrons utilisés par ce joueur dans les combats terminés de l’affrontement actuel, hors composition de Héros actuellement sélectionnée.",
      noPatronUse: "Aucune utilisation de familier patron enregistrée pour le moment.",
      usedPatronId: "Familier patron utilisé {id}",
      referenceCouldNotBeLoaded: "Impossible de charger la référence.",
      openDefenseReference: "Ouvrir la référence de défense",
      referenceClosedReopen: "Référence de défense fermée. Utilisez le bouton ⚔ pour la rouvrir.",
      failedDisplayDefenseReference: "Impossible d’afficher la référence de défense.",
      couldNotLoadBattleLogReference: "Impossible de charger la référence du journal de combat. Le Combat d'entraînement n’est pas affecté.",
      openGwAttackTarget: "Ouvrez l’écran d’une cible d’attaque de la Guerre de guildes.",
      defenseEditorOpen: "L’éditeur de défense est ouvert",
      ok: "OK",
      openCombatTraining: "Ouvrir le Combat d'entraînement",
      stateEmpty: "Vide",
      stateDefeated: "Vaincu",
      stateInBattle: "En combat",
      stateUnknown: "Inconnu",
      referenceRestoredFor: "Référence restaurée pour {defense}.",
      closeCurrentDefenseEditorFirst: "Fermez d’abord l’éditeur de défense actuel.",
      closeCurrentDefenseEditorBeforeSelecting: "Fermez l’éditeur de défense actuel avant de sélectionner une autre défense.",
      preparingDefense: "Préparation de la défense {slot}…",
      openedDefenseMax: "Défense {slot} ouverte en MAX.",
      loadingGame: "Chargement du jeu…",
      assignedToYou: "Assigné à vous",
      openDefense: "Ouvrir la défense {slot}",
      errorWaitGameLoad: "Attendez la fin du chargement du jeu, puis réessayez.",
      errorCloseCombatTraining: "Fermez le Combat d'entraînement avant d’ouvrir une autre défense.",
      errorSlotNotCurrentBuilding: "Cet emplacement de défense n’est pas dans le bâtiment actuel.",
      errorSlotNotAvailable: "Cet emplacement de défense n’est pas disponible pour l’entraînement actuellement.",
      errorSlotNoTeam: "Cet emplacement de défense n’a pas d’équipe.",
      errorTargetUser: "Impossible d’identifier le joueur ennemi.",
      errorMaxPattern: "Arrêt : impossible de déterminer le motif MAX de façon sûre.",
      errorMaxTeamTimeout: "L’équipe MAX n’a pas été prête à temps.",
      errorStopped: "Arrêté ({detail})",
    }),
    "it": Object.freeze({
      settings: "Impostazioni",
      minimize: "Riduci",
      close: "Chiudi",
      fontSize: "Dimensione testo",
      save: "Salva",
      reset: "Ripristina",
      combatTraining: "Combattimento di addestramento",
      defenseReference: "Riferimento difesa",
      defense: "Difesa",
      currentDefense: "Difesa attuale",
      warFlagId: "Bandiera di guerra {id}",
      noWarFlag: "Nessuna bandiera di guerra",
      flag: "Bandiera di guerra",
      flagId: "Bandiera di guerra {id}",
      patternSlotEmpty: "Slot Disegno {slot}: vuoto",
      loadingPlayer: "Caricamento di {player}…",
      loadingReference: "Caricamento riferimento…",
      pastSetup: "Formazione precedente",
      pastSetupInfo: "Informazioni sulla formazione precedente",
      pastSetupTip: "Viene mostrato quando la stessa formazione di Eroi del giocatore selezionato viene trovata nel registro di una battaglia reale precedente. Usala come riferimento quando assegni Animali sostenitori a questa formazione.",
      lastSeen: "Ultima volta {date}",
      noMatchingBattleLog: "Nessun registro di battaglia corrispondente trovato.",
      noMainPet: "Nessun animale principale",
      mainPetId: "Animale principale {id}",
      heroId: "Eroe {id}",
      heroDefeatedId: "Eroe {id} · SCONFITTO",
      defeatedUpper: "SCONFITTO",
      patronId: "Animale sostenitore {id}",
      usedPatrons: "Animali sostenitori usati",
      usedPatronsInfo: "Informazioni sugli Animali sostenitori usati",
      usedPatronsTip: "Mostra gli Animali sostenitori usati da questo giocatore nelle battaglie completate dello scontro attuale, esclusa la formazione di Eroi attualmente selezionata.",
      noPatronUse: "Nessun uso di Animale sostenitore registrato finora.",
      usedPatronId: "Animale sostenitore usato {id}",
      referenceCouldNotBeLoaded: "Impossibile caricare il riferimento.",
      openDefenseReference: "Apri riferimento difesa",
      referenceClosedReopen: "Riferimento difesa chiuso. Usa il pulsante ⚔ per riaprirlo.",
      failedDisplayDefenseReference: "Impossibile mostrare il riferimento difesa.",
      couldNotLoadBattleLogReference: "Impossibile caricare il riferimento dal registro di battaglia. Il Combattimento di addestramento non è interessato.",
      openGwAttackTarget: "Apri la schermata di un bersaglio d’attacco della Guerra delle Gilde.",
      defenseEditorOpen: "L’editor della difesa è aperto",
      ok: "OK",
      openCombatTraining: "Apri il Combattimento di addestramento",
      stateEmpty: "Vuoto",
      stateDefeated: "Sconfitto",
      stateInBattle: "In battaglia",
      stateUnknown: "Sconosciuto",
      referenceRestoredFor: "Riferimento ripristinato per {defense}.",
      closeCurrentDefenseEditorFirst: "Chiudi prima l’editor della difesa attuale.",
      closeCurrentDefenseEditorBeforeSelecting: "Chiudi l’editor della difesa attuale prima di selezionare un’altra difesa.",
      preparingDefense: "Preparazione difesa {slot}…",
      openedDefenseMax: "Difesa {slot} aperta in MAX.",
      loadingGame: "Caricamento gioco…",
      assignedToYou: "Assegnato a te",
      openDefense: "Apri difesa {slot}",
      errorWaitGameLoad: "Attendi il completamento del caricamento del gioco e riprova.",
      errorCloseCombatTraining: "Chiudi il Combattimento di addestramento prima di aprire un’altra difesa.",
      errorSlotNotCurrentBuilding: "Quello slot di difesa non si trova nell’edificio attuale.",
      errorSlotNotAvailable: "Quello slot di difesa non è attualmente disponibile per l’allenamento.",
      errorSlotNoTeam: "Quello slot di difesa non ha una squadra.",
      errorTargetUser: "Impossibile identificare il giocatore nemico.",
      errorMaxPattern: "Interrotto perché non è stato possibile determinare in sicurezza il Disegno MAX.",
      errorMaxTeamTimeout: "La squadra MAX non era pronta in tempo.",
      errorStopped: "Interrotto ({detail})",
    }),
    "de": Object.freeze({
      settings: "Einstellungen",
      minimize: "Minimieren",
      close: "Schließen",
      fontSize: "Schriftgröße",
      save: "Speichern",
      reset: "Zurücksetzen",
      combatTraining: "Kampftraining",
      defenseReference: "Verteidigungsreferenz",
      defense: "Verteidigung",
      currentDefense: "Aktuelle Verteidigung",
      warFlagId: "Kriegsflagge {id}",
      noWarFlag: "Keine Kriegsflagge",
      flag: "Kriegsflagge",
      flagId: "Kriegsflagge {id}",
      patternSlotEmpty: "Musterplatz {slot}: leer",
      loadingPlayer: "{player} wird geladen…",
      loadingReference: "Referenz wird geladen…",
      pastSetup: "Frühere Aufstellung",
      pastSetupInfo: "Informationen zur früheren Aufstellung",
      pastSetupTip: "Wird angezeigt, wenn dieselbe Heldenaufstellung des ausgewählten Spielers in einem früheren echten Kampfprotokoll gefunden wird. Nutze sie als Referenz, wenn du dieser Aufstellung Patronbegleiter zuweist.",
      lastSeen: "Zuletzt gesehen {date}",
      noMatchingBattleLog: "Kein passendes Kampfprotokoll gefunden.",
      noMainPet: "Kein Hauptbegleiter",
      mainPetId: "Hauptbegleiter {id}",
      heroId: "Held {id}",
      heroDefeatedId: "Held {id} · BESIEGT",
      defeatedUpper: "BESIEGT",
      patronId: "Patronbegleiter {id}",
      usedPatrons: "Verwendete Patronbegleiter",
      usedPatronsInfo: "Informationen zu verwendeten Patronbegleitern",
      usedPatronsTip: "Zeigt Patronbegleiter, die dieser Spieler in abgeschlossenen Kämpfen der aktuellen Begegnung verwendet hat, ausgenommen die aktuell ausgewählte Heldenaufstellung.",
      noPatronUse: "Noch keine Nutzung von Patronbegleitern erfasst.",
      usedPatronId: "Verwendeter Patronbegleiter {id}",
      referenceCouldNotBeLoaded: "Referenz konnte nicht geladen werden.",
      openDefenseReference: "Verteidigungsreferenz öffnen",
      referenceClosedReopen: "Verteidigungsreferenz geschlossen. Mit der Schaltfläche ⚔ kannst du sie wieder öffnen.",
      failedDisplayDefenseReference: "Verteidigungsreferenz konnte nicht angezeigt werden.",
      couldNotLoadBattleLogReference: "Kampfprotokoll-Referenz konnte nicht geladen werden. Das Kampftraining ist nicht betroffen.",
      openGwAttackTarget: "Öffne einen Angriffsziel-Bildschirm im Gildenkrieg.",
      defenseEditorOpen: "Verteidigungseditor ist geöffnet",
      ok: "OK",
      openCombatTraining: "Kampftraining öffnen",
      stateEmpty: "Leer",
      stateDefeated: "Besiegt",
      stateInBattle: "Im Kampf",
      stateUnknown: "Unbekannt",
      referenceRestoredFor: "Referenz für {defense} wiederhergestellt.",
      closeCurrentDefenseEditorFirst: "Schließe zuerst den aktuellen Verteidigungseditor.",
      closeCurrentDefenseEditorBeforeSelecting: "Schließe den aktuellen Verteidigungseditor, bevor du eine andere Verteidigung auswählst.",
      preparingDefense: "Verteidigung {slot} wird vorbereitet…",
      openedDefenseMax: "Verteidigung {slot} in MAX geöffnet.",
      loadingGame: "Spiel wird geladen…",
      assignedToYou: "Dir zugewiesen",
      openDefense: "Verteidigung {slot} öffnen",
      errorWaitGameLoad: "Warte, bis das Spiel vollständig geladen ist, und versuche es erneut.",
      errorCloseCombatTraining: "Schließe das Kampftraining, bevor du eine andere Verteidigung öffnest.",
      errorSlotNotCurrentBuilding: "Dieser Verteidigungsplatz befindet sich nicht im aktuellen Gebäude.",
      errorSlotNotAvailable: "Dieser Verteidigungsplatz ist derzeit nicht für das Training verfügbar.",
      errorSlotNoTeam: "Dieser Verteidigungsplatz hat kein Team.",
      errorTargetUser: "Der gegnerische Spieler konnte nicht ermittelt werden.",
      errorMaxPattern: "Angehalten, weil das MAX-Muster nicht sicher ermittelt werden konnte.",
      errorMaxTeamTimeout: "Das MAX-Team war nicht rechtzeitig bereit.",
      errorStopped: "Angehalten ({detail})",
    }),
    "es": Object.freeze({
      settings: "Ajustes",
      minimize: "Minimizar",
      close: "Cerrar",
      fontSize: "Tamaño de fuente",
      save: "Guardar",
      reset: "Restablecer",
      combatTraining: "Entrenamiento de combate",
      defenseReference: "Referencia de defensa",
      defense: "Defensa",
      currentDefense: "Defensa actual",
      warFlagId: "Bandera de guerra {id}",
      noWarFlag: "Sin bandera de guerra",
      flag: "Bandera de guerra",
      flagId: "Bandera de guerra {id}",
      patternSlotEmpty: "Ranura de diseño {slot}: vacía",
      loadingPlayer: "Cargando {player}…",
      loadingReference: "Cargando referencia…",
      pastSetup: "Formación anterior",
      pastSetupInfo: "Información de la formación anterior",
      pastSetupTip: "Se muestra cuando la misma formación de Héroes del jugador seleccionado aparece en un registro de batalla real anterior. Úsala como referencia al asignar Mascotas de asistencia a esta formación.",
      lastSeen: "Visto por última vez {date}",
      noMatchingBattleLog: "No se encontró un registro de batalla coincidente.",
      noMainPet: "Sin mascota principal",
      mainPetId: "Mascota principal {id}",
      heroId: "Héroe {id}",
      heroDefeatedId: "Héroe {id} · DERROTADO",
      defeatedUpper: "DERROTADO",
      patronId: "Mascota de asistencia {id}",
      usedPatrons: "Mascotas de asistencia usadas",
      usedPatronsInfo: "Información sobre Mascotas de asistencia usadas",
      usedPatronsTip: "Muestra las Mascotas de asistencia que este jugador usó en batallas completadas del enfrentamiento actual, excepto la formación de Héroes seleccionada actualmente.",
      noPatronUse: "Todavía no se registró uso de Mascotas de asistencia.",
      usedPatronId: "Mascota de asistencia usada {id}",
      referenceCouldNotBeLoaded: "No se pudo cargar la referencia.",
      openDefenseReference: "Abrir referencia de defensa",
      referenceClosedReopen: "La referencia de defensa se cerró. Usa el botón ⚔ para volver a abrirla.",
      failedDisplayDefenseReference: "No se pudo mostrar la referencia de defensa.",
      couldNotLoadBattleLogReference: "No se pudo cargar la referencia del registro de batalla. El Entrenamiento de combate no se ve afectado.",
      openGwAttackTarget: "Abre la pantalla de un objetivo de ataque de la Guerra de Gremios.",
      defenseEditorOpen: "El editor de defensa está abierto",
      ok: "OK",
      openCombatTraining: "Abrir Entrenamiento de combate",
      stateEmpty: "Vacío",
      stateDefeated: "Derrotado",
      stateInBattle: "En batalla",
      stateUnknown: "Desconocido",
      referenceRestoredFor: "Referencia restaurada para {defense}.",
      closeCurrentDefenseEditorFirst: "Cierra primero el editor de defensa actual.",
      closeCurrentDefenseEditorBeforeSelecting: "Cierra el editor de defensa actual antes de seleccionar otra defensa.",
      preparingDefense: "Preparando defensa {slot}…",
      openedDefenseMax: "Defensa {slot} abierta en MAX.",
      loadingGame: "Cargando juego…",
      assignedToYou: "Asignado a ti",
      openDefense: "Abrir defensa {slot}",
      errorWaitGameLoad: "Espera a que el juego termine de cargar e inténtalo de nuevo.",
      errorCloseCombatTraining: "Cierra el Entrenamiento de combate antes de abrir otra defensa.",
      errorSlotNotCurrentBuilding: "Esa ranura de defensa no está en el edificio actual.",
      errorSlotNotAvailable: "Esa ranura de defensa no está disponible para entrenamiento en este momento.",
      errorSlotNoTeam: "Esa ranura de defensa no tiene equipo.",
      errorTargetUser: "No se pudo identificar al jugador enemigo.",
      errorMaxPattern: "Se detuvo porque no se pudo determinar de forma segura el diseño MAX.",
      errorMaxTeamTimeout: "El equipo MAX no estuvo listo a tiempo.",
      errorStopped: "Detenido ({detail})",
    }),
    "zh-CN": Object.freeze({
      settings: "设置",
      minimize: "最小化",
      close: "关闭",
      fontSize: "字体大小",
      save: "保存",
      reset: "重置",
      combatTraining: "作战训练",
      defenseReference: "防守参考",
      defense: "防守",
      currentDefense: "当前防守",
      warFlagId: "战旗 {id}",
      noWarFlag: "无战旗",
      flag: "战旗",
      flagId: "战旗 {id}",
      patternSlotEmpty: "图案槽位 {slot}:空",
      loadingPlayer: "正在加载 {player}…",
      loadingReference: "正在加载参考信息…",
      pastSetup: "过去阵容",
      pastSetupInfo: "过去阵容信息",
      pastSetupTip: "当所选玩家的相同英雄阵容出现在过去的真实战斗日志中时显示。为该阵容分配庇护宠物时,可将其作为参考。",
      lastSeen: "最后出现 {date}",
      noMatchingBattleLog: "未找到匹配的战斗日志。",
      noMainPet: "无主宠物",
      mainPetId: "主宠物 {id}",
      heroId: "英雄 {id}",
      heroDefeatedId: "英雄 {id} · 已击败",
      defeatedUpper: "已击败",
      patronId: "庇护宠物 {id}",
      usedPatrons: "已使用的庇护宠物",
      usedPatronsInfo: "已使用庇护宠物的信息",
      usedPatronsTip: "显示该玩家在当前对战已完成战斗中使用过的庇护宠物,不包括当前选中的英雄阵容。",
      noPatronUse: "尚无庇护宠物使用记录。",
      usedPatronId: "已使用庇护宠物 {id}",
      referenceCouldNotBeLoaded: "无法加载参考信息。",
      openDefenseReference: "打开防守参考",
      referenceClosedReopen: "防守参考已关闭。使用 ⚔ 按钮可重新打开。",
      failedDisplayDefenseReference: "无法显示防守参考。",
      couldNotLoadBattleLogReference: "无法加载战斗日志参考。作战训练不受影响。",
      openGwAttackTarget: "打开公会战的攻击目标界面。",
      defenseEditorOpen: "防守编辑器已打开",
      ok: "确定",
      openCombatTraining: "打开作战训练",
      stateEmpty: "空",
      stateDefeated: "已击败",
      stateInBattle: "战斗中",
      stateUnknown: "未知",
      referenceRestoredFor: "已恢复 {defense} 的参考信息。",
      closeCurrentDefenseEditorFirst: "请先关闭当前防守编辑器。",
      closeCurrentDefenseEditorBeforeSelecting: "选择其他防守前,请关闭当前防守编辑器。",
      preparingDefense: "正在准备防守 {slot}…",
      openedDefenseMax: "已以 MAX 打开防守 {slot}。",
      loadingGame: "正在加载游戏…",
      assignedToYou: "分配给你",
      openDefense: "打开防守 {slot}",
      errorWaitGameLoad: "请等待游戏加载完成后再试。",
      errorCloseCombatTraining: "打开其他防守前,请先关闭作战训练。",
      errorSlotNotCurrentBuilding: "该防守槽位不在当前建筑中。",
      errorSlotNotAvailable: "该防守槽位当前不可用于训练。",
      errorSlotNoTeam: "该防守槽位没有队伍。",
      errorTargetUser: "无法确定敌方玩家。",
      errorMaxPattern: "由于无法安全确定 MAX 图案,已停止。",
      errorMaxTeamTimeout: "MAX 队伍未能及时准备完成。",
      errorStopped: "已停止({detail})",
    }),
    "zh-TW": Object.freeze({
      settings: "設定",
      minimize: "最小化",
      close: "關閉",
      fontSize: "字體大小",
      save: "儲存",
      reset: "重設",
      combatTraining: "對戰訓練",
      defenseReference: "防守參考",
      defense: "防守",
      currentDefense: "目前防守",
      warFlagId: "戰旗 {id}",
      noWarFlag: "無戰旗",
      flag: "戰旗",
      flagId: "戰旗 {id}",
      patternSlotEmpty: "圖案欄位 {slot}:空",
      loadingPlayer: "正在載入 {player}…",
      loadingReference: "正在載入參考資訊…",
      pastSetup: "過去陣容",
      pastSetupInfo: "過去陣容資訊",
      pastSetupTip: "當所選玩家的相同英雄陣容出現在過去的真實戰鬥紀錄中時顯示。為此陣容指派守護寵物時,可將其作為參考。",
      lastSeen: "最後出現 {date}",
      noMatchingBattleLog: "找不到符合的戰鬥紀錄。",
      noMainPet: "無主要寵物",
      mainPetId: "主要寵物 {id}",
      heroId: "英雄 {id}",
      heroDefeatedId: "英雄 {id} · 已擊敗",
      defeatedUpper: "已擊敗",
      patronId: "守護寵物 {id}",
      usedPatrons: "已使用的守護寵物",
      usedPatronsInfo: "已使用守護寵物的資訊",
      usedPatronsTip: "顯示該玩家在目前對戰已完成戰鬥中使用過的守護寵物,不包含目前選取的英雄陣容。",
      noPatronUse: "尚無守護寵物使用紀錄。",
      usedPatronId: "已使用守護寵物 {id}",
      referenceCouldNotBeLoaded: "無法載入參考資訊。",
      openDefenseReference: "開啟防守參考",
      referenceClosedReopen: "防守參考已關閉。使用 ⚔ 按鈕可重新開啟。",
      failedDisplayDefenseReference: "無法顯示防守參考。",
      couldNotLoadBattleLogReference: "無法載入戰鬥紀錄參考。對戰訓練不受影響。",
      openGwAttackTarget: "開啟公會戰的攻擊目標畫面。",
      defenseEditorOpen: "防守編輯器已開啟",
      ok: "確定",
      openCombatTraining: "開啟對戰訓練",
      stateEmpty: "空",
      stateDefeated: "已擊敗",
      stateInBattle: "戰鬥中",
      stateUnknown: "未知",
      referenceRestoredFor: "已恢復 {defense} 的參考資訊。",
      closeCurrentDefenseEditorFirst: "請先關閉目前的防守編輯器。",
      closeCurrentDefenseEditorBeforeSelecting: "選擇其他防守前,請關閉目前的防守編輯器。",
      preparingDefense: "正在準備防守 {slot}…",
      openedDefenseMax: "已以 MAX 開啟防守 {slot}。",
      loadingGame: "正在載入遊戲…",
      assignedToYou: "指派給你",
      openDefense: "開啟防守 {slot}",
      errorWaitGameLoad: "請等待遊戲載入完成後再試一次。",
      errorCloseCombatTraining: "開啟其他防守前,請先關閉對戰訓練。",
      errorSlotNotCurrentBuilding: "該防守欄位不在目前建築中。",
      errorSlotNotAvailable: "該防守欄位目前無法用於訓練。",
      errorSlotNoTeam: "該防守欄位沒有隊伍。",
      errorTargetUser: "無法判定敵方玩家。",
      errorMaxPattern: "因無法安全判定 MAX 圖案,已停止。",
      errorMaxTeamTimeout: "MAX 隊伍未能及時準備完成。",
      errorStopped: "已停止({detail})",
    }),
    "ja": Object.freeze({
      settings: "設定",
      minimize: "最小化",
      close: "閉じる",
      fontSize: "文字サイズ",
      save: "保存",
      reset: "リセット",
      combatTraining: "戦闘訓練",
      defenseReference: "防衛参照",
      defense: "防衛",
      currentDefense: "現在の防衛",
      warFlagId: "戦旗 {id}",
      noWarFlag: "戦旗なし",
      flag: "戦旗",
      flagId: "戦旗 {id}",
      patternSlotEmpty: "模様スロット {slot}: 空",
      loadingPlayer: "{player} を読み込み中…",
      loadingReference: "参照情報を読み込み中…",
      pastSetup: "過去の編成",
      pastSetupInfo: "過去の編成について",
      pastSetupTip: "選択したプレイヤーについて、同じHero編成が過去の実戦ログで見つかった場合に表示されます。この編成に支援ペットを設定するときの参考にできます。",
      lastSeen: "最終確認 {date}",
      noMatchingBattleLog: "一致する戦闘ログが見つかりません。",
      noMainPet: "メインペットなし",
      mainPetId: "メインペット {id}",
      heroId: "Hero {id}",
      heroDefeatedId: "Hero {id} · 撃破済み",
      defeatedUpper: "撃破済み",
      patronId: "支援ペット {id}",
      usedPatrons: "使用済み支援ペット",
      usedPatronsInfo: "使用済み支援ペットについて",
      usedPatronsTip: "現在選択しているHero編成を除き、このプレイヤーが現在の対戦中の完了済み戦闘で使用した支援ペットを表示します。",
      noPatronUse: "支援ペットの使用記録はまだありません。",
      usedPatronId: "使用済み支援ペット {id}",
      referenceCouldNotBeLoaded: "防衛参照を読み込めませんでした。",
      openDefenseReference: "防衛参照を開く",
      referenceClosedReopen: "防衛参照を閉じました。⚔ボタンから再度開けます。",
      failedDisplayDefenseReference: "防衛参照を表示できませんでした。",
      couldNotLoadBattleLogReference: "戦闘ログの参照情報を読み込めませんでした。戦闘訓練には影響ありません。",
      openGwAttackTarget: "ギルド戦の攻撃対象画面を開いてください。",
      defenseEditorOpen: "防衛編集画面が開いています",
      ok: "OK",
      openCombatTraining: "戦闘訓練を開く",
      stateEmpty: "空",
      stateDefeated: "撃破済み",
      stateInBattle: "戦闘中",
      stateUnknown: "不明",
      referenceRestoredFor: "{defense} の参照情報を復元しました。",
      closeCurrentDefenseEditorFirst: "先に現在の防衛編集画面を閉じてください。",
      closeCurrentDefenseEditorBeforeSelecting: "別の防衛を選択する前に、現在の防衛編集画面を閉じてください。",
      preparingDefense: "防衛 {slot} を準備中…",
      openedDefenseMax: "防衛 {slot} をMAXで開きました。",
      loadingGame: "ゲームを読み込み中…",
      assignedToYou: "あなたの担当",
      openDefense: "防衛 {slot} を開く",
      errorWaitGameLoad: "ゲームの読み込み完了を待ってから、もう一度試してください。",
      errorCloseCombatTraining: "別の防衛を開く前に戦闘訓練を閉じてください。",
      errorSlotNotCurrentBuilding: "その防衛枠は現在の建物にありません。",
      errorSlotNotAvailable: "その防衛枠は現在トレーニングに使用できません。",
      errorSlotNoTeam: "その防衛枠にはチームがありません。",
      errorTargetUser: "敵プレイヤーを特定できませんでした。",
      errorMaxPattern: "MAXの模様を安全に特定できなかったため停止しました。",
      errorMaxTeamTimeout: "MAXチームの準備が時間内に完了しませんでした。",
      errorStopped: "停止しました ({detail})",
    }),
    "ko": Object.freeze({
      settings: "설정",
      minimize: "최소화",
      close: "닫기",
      fontSize: "글꼴 크기",
      save: "저장",
      reset: "초기화",
      combatTraining: "전투 훈련",
      defenseReference: "방어 참고",
      defense: "방어",
      currentDefense: "현재 방어",
      warFlagId: "전쟁 깃발 {id}",
      noWarFlag: "전쟁 깃발 없음",
      flag: "전쟁 깃발",
      flagId: "전쟁 깃발 {id}",
      patternSlotEmpty: "패턴 슬롯 {slot}: 비어 있음",
      loadingPlayer: "{player} 불러오는 중…",
      loadingReference: "참고 정보 불러오는 중…",
      pastSetup: "과거 편성",
      pastSetupInfo: "과거 편성 정보",
      pastSetupTip: "선택한 플레이어의 동일한 영웅 편성이 과거 실제 전투 기록에서 발견되면 표시됩니다. 이 편성에 보호 펫을 지정할 때 참고할 수 있습니다.",
      lastSeen: "마지막 확인 {date}",
      noMatchingBattleLog: "일치하는 전투 기록을 찾지 못했습니다.",
      noMainPet: "메인 펫 없음",
      mainPetId: "메인 펫 {id}",
      heroId: "영웅 {id}",
      heroDefeatedId: "영웅 {id} · 격파됨",
      defeatedUpper: "격파됨",
      patronId: "보호 펫 {id}",
      usedPatrons: "사용한 보호 펫",
      usedPatronsInfo: "사용한 보호 펫 정보",
      usedPatronsTip: "현재 선택한 영웅 편성을 제외하고, 이 플레이어가 현재 대전의 완료된 전투에서 사용한 보호 펫을 표시합니다.",
      noPatronUse: "아직 보호 펫 사용 기록이 없습니다.",
      usedPatronId: "사용한 보호 펫 {id}",
      referenceCouldNotBeLoaded: "참고 정보를 불러올 수 없습니다.",
      openDefenseReference: "방어 참고 열기",
      referenceClosedReopen: "방어 참고를 닫았습니다. ⚔ 버튼으로 다시 열 수 있습니다.",
      failedDisplayDefenseReference: "방어 참고를 표시할 수 없습니다.",
      couldNotLoadBattleLogReference: "전투 기록 참고 정보를 불러올 수 없습니다. 전투 훈련에는 영향이 없습니다.",
      openGwAttackTarget: "길드 워 공격 대상 화면을 여세요.",
      defenseEditorOpen: "방어 편집기가 열려 있습니다",
      ok: "확인",
      openCombatTraining: "전투 훈련 열기",
      stateEmpty: "비어 있음",
      stateDefeated: "격파됨",
      stateInBattle: "전투 중",
      stateUnknown: "알 수 없음",
      referenceRestoredFor: "{defense} 참고 정보를 복원했습니다.",
      closeCurrentDefenseEditorFirst: "먼저 현재 방어 편집기를 닫으세요.",
      closeCurrentDefenseEditorBeforeSelecting: "다른 방어를 선택하기 전에 현재 방어 편집기를 닫으세요.",
      preparingDefense: "방어 {slot} 준비 중…",
      openedDefenseMax: "방어 {slot}을(를) MAX로 열었습니다.",
      loadingGame: "게임 불러오는 중…",
      assignedToYou: "나에게 할당됨",
      openDefense: "방어 {slot} 열기",
      errorWaitGameLoad: "게임 로딩이 끝난 뒤 다시 시도하세요.",
      errorCloseCombatTraining: "다른 방어를 열기 전에 전투 훈련을 닫으세요.",
      errorSlotNotCurrentBuilding: "해당 방어 슬롯은 현재 건물에 없습니다.",
      errorSlotNotAvailable: "해당 방어 슬롯은 현재 훈련에 사용할 수 없습니다.",
      errorSlotNoTeam: "해당 방어 슬롯에 팀이 없습니다.",
      errorTargetUser: "적 플레이어를 확인할 수 없습니다.",
      errorMaxPattern: "MAX 패턴을 안전하게 확인할 수 없어 중지했습니다.",
      errorMaxTeamTimeout: "MAX 팀이 제시간에 준비되지 않았습니다.",
      errorStopped: "중지됨 ({detail})",
    }),
    "pl": Object.freeze({
      settings: "Ustawienia",
      minimize: "Minimalizuj",
      close: "Zamknij",
      fontSize: "Rozmiar czcionki",
      save: "Zapisz",
      reset: "Resetuj",
      combatTraining: "Trening walki",
      defenseReference: "Podgląd obrony",
      defense: "Obrona",
      currentDefense: "Aktualna obrona",
      warFlagId: "Flaga wojenna {id}",
      noWarFlag: "Brak flagi wojennej",
      flag: "Flaga wojenna",
      flagId: "Flaga wojenna {id}",
      patternSlotEmpty: "Miejsce wzoru {slot}: puste",
      loadingPlayer: "Wczytywanie {player}…",
      loadingReference: "Wczytywanie podglądu…",
      pastSetup: "Poprzedni skład",
      pastSetupInfo: "Informacje o poprzednim składzie",
      pastSetupTip: "Wyświetlane, gdy taki sam skład Bohaterów wybranego gracza zostanie znaleziony w zapisie wcześniejszej prawdziwej walki. Użyj go jako punktu odniesienia przy przypisywaniu chowańców-patronów do tego składu.",
      lastSeen: "Ostatnio widziano {date}",
      noMatchingBattleLog: "Nie znaleziono pasującego zapisu walki.",
      noMainPet: "Brak głównego chowańca",
      mainPetId: "Główny chowaniec {id}",
      heroId: "Bohater {id}",
      heroDefeatedId: "Bohater {id} · POKONANY",
      defeatedUpper: "POKONANY",
      patronId: "Chowaniec-patron {id}",
      usedPatrons: "Użyte chowańce-patroni",
      usedPatronsInfo: "Informacje o użytych chowańcach-patronach",
      usedPatronsTip: "Pokazuje chowańce-patronów użyte przez tego gracza w zakończonych walkach bieżącego starcia, z wyłączeniem aktualnie wybranego składu Bohaterów.",
      noPatronUse: "Nie zarejestrowano jeszcze użycia chowańców-patronów.",
      usedPatronId: "Użyty chowaniec-patron {id}",
      referenceCouldNotBeLoaded: "Nie udało się wczytać podglądu.",
      openDefenseReference: "Otwórz podgląd obrony",
      referenceClosedReopen: "Podgląd obrony został zamknięty. Użyj przycisku ⚔, aby otworzyć go ponownie.",
      failedDisplayDefenseReference: "Nie udało się wyświetlić podglądu obrony.",
      couldNotLoadBattleLogReference: "Nie udało się wczytać danych z zapisu walki. Trening walki działa nadal.",
      openGwAttackTarget: "Otwórz ekran celu ataku w Wojnie Gildii.",
      defenseEditorOpen: "Edytor obrony jest otwarty",
      ok: "OK",
      openCombatTraining: "Otwórz trening walki",
      stateEmpty: "Puste",
      stateDefeated: "Pokonany",
      stateInBattle: "W walce",
      stateUnknown: "Nieznany",
      referenceRestoredFor: "Przywrócono podgląd dla {defense}.",
      closeCurrentDefenseEditorFirst: "Najpierw zamknij bieżący edytor obrony.",
      closeCurrentDefenseEditorBeforeSelecting: "Zamknij bieżący edytor obrony przed wybraniem innej obrony.",
      preparingDefense: "Przygotowywanie obrony {slot}…",
      openedDefenseMax: "Obrona {slot} otwarta w MAX.",
      loadingGame: "Wczytywanie gry…",
      assignedToYou: "Przydzielono tobie",
      openDefense: "Otwórz obronę {slot}",
      errorWaitGameLoad: "Poczekaj na zakończenie wczytywania gry i spróbuj ponownie.",
      errorCloseCombatTraining: "Zamknij trening walki przed otwarciem innej obrony.",
      errorSlotNotCurrentBuilding: "To miejsce obrony nie znajduje się w bieżącym budynku.",
      errorSlotNotAvailable: "To miejsce obrony nie jest obecnie dostępne do treningu.",
      errorSlotNoTeam: "To miejsce obrony nie ma drużyny.",
      errorTargetUser: "Nie udało się ustalić gracza przeciwnika.",
      errorMaxPattern: "Zatrzymano, ponieważ nie udało się bezpiecznie ustalić wzoru MAX.",
      errorMaxTeamTimeout: "Drużyna MAX nie była gotowa na czas.",
      errorStopped: "Zatrzymano ({detail})",
    }),
    "th": Object.freeze({
      settings: "การตั้งค่า",
      minimize: "ย่อ",
      close: "ปิด",
      fontSize: "ขนาดตัวอักษร",
      save: "บันทึก",
      reset: "รีเซ็ต",
      combatTraining: "การฝึกซ้อมการต่อสู้",
      defenseReference: "ข้อมูลอ้างอิงการป้องกัน",
      defense: "การป้องกัน",
      currentDefense: "การป้องกันปัจจุบัน",
      warFlagId: "ธงสงคราม {id}",
      noWarFlag: "ไม่มีธงสงคราม",
      flag: "ธงสงคราม",
      flagId: "ธงสงคราม {id}",
      patternSlotEmpty: "ช่องรูปแบบ {slot}: ว่าง",
      loadingPlayer: "กำลังโหลด {player}…",
      loadingReference: "กำลังโหลดข้อมูลอ้างอิง…",
      pastSetup: "รูปแบบทีมก่อนหน้า",
      pastSetupInfo: "ข้อมูลรูปแบบทีมก่อนหน้า",
      pastSetupTip: "จะแสดงเมื่อพบรูปแบบฮีโร่เดียวกันของผู้เล่นที่เลือกในบันทึกการต่อสู้จริงที่ผ่านมา ใช้เป็นข้อมูลอ้างอิงเมื่อตั้งค่าสัตว์เลี้ยงติดตามให้กับรูปแบบทีมนี้",
      lastSeen: "พบล่าสุด {date}",
      noMatchingBattleLog: "ไม่พบบันทึกการต่อสู้ที่ตรงกัน",
      noMainPet: "ไม่มีสัตว์เลี้ยงหลัก",
      mainPetId: "สัตว์เลี้ยงหลัก {id}",
      heroId: "ฮีโร่ {id}",
      heroDefeatedId: "ฮีโร่ {id} · ถูกกำจัด",
      defeatedUpper: "ถูกกำจัด",
      patronId: "สัตว์เลี้ยงติดตาม {id}",
      usedPatrons: "สัตว์เลี้ยงติดตามที่ใช้",
      usedPatronsInfo: "ข้อมูลสัตว์เลี้ยงติดตามที่ใช้",
      usedPatronsTip: "แสดงสัตว์เลี้ยงติดตามที่ผู้เล่นนี้ใช้ในการต่อสู้ที่จบแล้วของการพบกันปัจจุบัน โดยไม่รวมรูปแบบฮีโร่ที่เลือกอยู่",
      noPatronUse: "ยังไม่มีบันทึกการใช้สัตว์เลี้ยงติดตาม",
      usedPatronId: "สัตว์เลี้ยงติดตามที่ใช้ {id}",
      referenceCouldNotBeLoaded: "ไม่สามารถโหลดข้อมูลอ้างอิงได้",
      openDefenseReference: "เปิดข้อมูลอ้างอิงการป้องกัน",
      referenceClosedReopen: "ปิดข้อมูลอ้างอิงการป้องกันแล้ว ใช้ปุ่ม ⚔ เพื่อเปิดอีกครั้ง",
      failedDisplayDefenseReference: "ไม่สามารถแสดงข้อมูลอ้างอิงการป้องกันได้",
      couldNotLoadBattleLogReference: "ไม่สามารถโหลดข้อมูลอ้างอิงจากบันทึกการต่อสู้ได้ การฝึกซ้อมการต่อสู้ยังใช้งานได้ตามปกติ",
      openGwAttackTarget: "เปิดหน้าจอเป้าหมายโจมตีของสงครามกิลด์",
      defenseEditorOpen: "ตัวแก้ไขการป้องกันเปิดอยู่",
      ok: "ตกลง",
      openCombatTraining: "เปิดการฝึกซ้อมการต่อสู้",
      stateEmpty: "ว่าง",
      stateDefeated: "ถูกกำจัด",
      stateInBattle: "กำลังต่อสู้",
      stateUnknown: "ไม่ทราบ",
      referenceRestoredFor: "กู้คืนข้อมูลอ้างอิงสำหรับ {defense} แล้ว",
      closeCurrentDefenseEditorFirst: "ปิดตัวแก้ไขการป้องกันปัจจุบันก่อน",
      closeCurrentDefenseEditorBeforeSelecting: "ปิดตัวแก้ไขการป้องกันปัจจุบันก่อนเลือกการป้องกันอื่น",
      preparingDefense: "กำลังเตรียมการป้องกัน {slot}…",
      openedDefenseMax: "เปิดการป้องกัน {slot} ใน MAX แล้ว",
      loadingGame: "กำลังโหลดเกม…",
      assignedToYou: "มอบหมายให้คุณ",
      openDefense: "เปิดการป้องกัน {slot}",
      errorWaitGameLoad: "รอให้เกมโหลดเสร็จแล้วลองอีกครั้ง",
      errorCloseCombatTraining: "ปิดการฝึกซ้อมการต่อสู้ก่อนเปิดการป้องกันอื่น",
      errorSlotNotCurrentBuilding: "ช่องการป้องกันนี้ไม่ได้อยู่ในอาคารปัจจุบัน",
      errorSlotNotAvailable: "ช่องการป้องกันนี้ยังไม่พร้อมสำหรับการฝึกในขณะนี้",
      errorSlotNoTeam: "ช่องการป้องกันนี้ไม่มีทีม",
      errorTargetUser: "ไม่สามารถระบุผู้เล่นฝ่ายตรงข้ามได้",
      errorMaxPattern: "หยุดแล้ว เนื่องจากไม่สามารถระบุรูปแบบ MAX ได้อย่างปลอดภัย",
      errorMaxTeamTimeout: "ทีม MAX ไม่พร้อมภายในเวลาที่กำหนด",
      errorStopped: "หยุดแล้ว ({detail})",
    }),
  });

  const UI_LANG_ALIASES = Object.freeze({
    "en": "en",
    "ru": "ru",
    "pt": "pt",
    "pt-br": "pt",
    "pt-pt": "pt",
    "fr": "fr",
    "it": "it",
    "de": "de",
    "es": "es",
    "zh": "zh-CN",
    "zh-cn": "zh-CN",
    "zh-hans": "zh-CN",
    "zh-sg": "zh-CN",
    "cn": "zh-CN",
    "zh-tw": "zh-TW",
    "zh-hant": "zh-TW",
    "zh-hk": "zh-TW",
    "tw": "zh-TW",
    "ja": "ja",
    "jp": "ja",
    "ko": "ko",
    "kr": "ko",
    "pl": "pl",
    "th": "th",
  });

  function normalizeUiLanguage(value) {
    const raw = String(value ?? '').trim().toLowerCase().replace(/_/g, '-');
    if (!raw) return 'en';
    return UI_LANG_ALIASES[raw] || UI_LANG_ALIASES[raw.split('-')[0]] || 'en';
  }

  function getUiLanguage() {
    return normalizeUiLanguage(
      window.NXFlashVars?.interface_lang ||
      document.documentElement.lang ||
      'en'
    );
  }

  function t(key, vars = {}) {
    const lang = getUiLanguage();
    let template = I18N[lang]?.[key] ?? I18N.en[key] ?? key;

    const nativeName = NATIVE_I18N_KEYS[key];
    if (nativeName) {
      template = nativeUiText(nativeName, template);
    }

    return String(template).replace(/\{([A-Za-z0-9_]+)\}/g, (match, name) =>
      Object.prototype.hasOwnProperty.call(vars, name) ? String(vars[name]) : match
    );
  }

  function nativeLabelWithId(nativeName, fallbackKey, id) {
    const fallback = t(fallbackKey, { id });
    const label = nativeUiText(nativeName, '');
    return label ? `${label} ${id}` : fallback;
  }

  function warFlagIdText(id) {
    return nativeLabelWithId('warFlag', 'warFlagId', id);
  }

  function patronPetIdText(id) {
    return nativeLabelWithId('patronPet', 'patronId', id);
  }
  const CLASS = Object.freeze({
    popupManager: 'game.mediator.gui.popup.GamePopupManager',
    listCollection: 'feathers.data.ListCollection',
    demoPopup: 'game.view.popup.demoBattle.DemoBattleCreatePopup',
    demoDefenseGatherPopup: 'game.view.popup.demoBattle.teamGather.DemoBattleDefenseTeamGatherPopup',
    demoAttackGatherPopup: 'game.view.popup.demoBattle.teamGather.DemoBattleAttackTeamGatherPopup',
    demoMediator: 'game.view.popup.demoBattle.DemoBattleCreatePopupMediator',
    demoPresets: 'game.view.popup.demoBattle.DemoBattlePresets',
    battleTeam: 'game.data.storage.battle.BattleTeam',
    battlePreloaderPopup: 'game.view.popup.battle.BattlePreloaderPopup',
    battleViewScreen: 'game.mediator.gui.popup.battle.BattleViewScreen',
    booleanPropertyWriteable: 'engine.core.utils.property.BooleanPropertyWriteable',
    userInfo: 'game.model.user.UserInfo',
    playerBannerEntry: 'game.model.user.banner.PlayerBannerEntry',
    playerBannerVO: 'game.mediator.gui.popup.banner.PlayerBannerEntryValueObject',
    intMap: 'haxe.ds.IntMap',
    inventoryItemType: 'game.data.storage._enum.lib.InventoryItemType',
    bannerStoneStorage: 'game.data.storage.resource.BannerStoneDescriptionStorage',
    bannerStoneDescription: 'game.data.storage.resource.BannerStoneDescription',
    mechanicDescription: 'game.data.storage.mechanic.MechanicDescription',
    commandDemoBattleStart: 'game.command.rpc.demoBattle.CommandDemoBattleStart',
    cowAttackBuffVO: 'game.mechanics.cross_clan_war.popup.attack.CrossClanWarAttackBuffVO',

    cowAttackPopup: 'game.mechanics.cross_clan_war.popup.attack.CrossClanWarAttackPopup',
    cowAttackMediator: 'game.mechanics.cross_clan_war.popup.attack.CrossClanWarAttackPopupMediator',
    cowSlotVO: 'game.mechanics.cross_clan_war.popup.war.CrossClanWarCurrentSlotVO',
    cowCurrentSlot: 'game.mechanics.cross_clan_war.model.CrossClanWarCurrentSlot',
    cowBattleTeamWithState: 'game.mechanics.cross_clan_war.model.CrossClanWarBattleTeamWithState',
    cowAttackRenderer: 'game.mechanics.cross_clan_war.popup.attack.CrossClanWarAttackListItemRenderer',
    cowCommandList: 'game.mechanics.cross_clan_war.command.CrossClanWarCommandList',
    cowLogItem: 'game.mechanics.cross_clan_war.model.CrossClanWarLogItem',
    cowLogWarVO: 'game.mechanics.cross_clan_war.popup.log.wars.CrossClanWarLogVO',
    cowLogBattleVO: 'game.mechanics.cross_clan_war.popup.log.battles.CrossClanWarLogBattleVO',
    cowLogBattlePopupMediator: 'game.mechanics.cross_clan_war.popup.log.battles.CrossClanWarLogBattlePopupMediator',
    clanBasicInfoVO: 'game.model.user.clan.ClanBasicInfoValueObject',
    gameModel: 'game.model.GameModel',
    commandManager: 'game.command.CommandManager',
    rpcCreator: 'game.util.rpc.RpcCreator',
    stringMap: 'haxe.ds.StringMap',
    dataStorage: 'game.data.storage.DataStorage',
    heroEntryVO: 'game.mediator.gui.popup.hero.HeroEntryValueObject',
    subTexture: 'starling.textures.SubTexture',
    bitmapData: 'openfl.display.BitmapData',
    limeImage: 'lime.graphics.Image',
    limeImageBuffer: 'lime.graphics.ImageBuffer',
    bannerDescriptionStorage: 'game.data.storage.banner.BannerDescriptionStorage',
    bannerDescription: 'game.data.storage.banner.BannerDescription',
    assetStorage: 'game.assets.storage.AssetStorage',
    inventoryAssetStorage: 'game.assets.storage.InventoryAssetStorage',
    assetStorageUtil: 'game.assets.storage.AssetStorageUtil',
    iconContentProvider: 'game.view.gui.components.inventory.IconContentProvider',
    atlasTextureIconAsset: 'game.assets.icon.AtlasTextureIconAsset',
    iconAtlasAsset: 'game.assets.storage.IconAtlasAsset',

    gwAttackPopup: 'game.mechanics.clan_war.popup.war.attack.ClanWarAttackPopup',
    gwAttackMediator: 'game.mechanics.clan_war.mediator.ClanWarAttackPopupMediator',
    gwSlotVO: 'game.mechanics.clan_war.model.ClanWarSlotValueObject',
    gwDefenderVO: 'game.mechanics.clan_war.model.ClanWarDefenderValueObject',
    gwCommandList: 'game.mechanics.clan_war.model.command.ClanWarCommandList',
    gwAvailableHistoryCommand: 'game.mechanics.clan_war.model.command.CommandClanWarGetAvailableHistory',
    gwDayHistoryCommand: 'game.mechanics.clan_war.model.command.CommandClanWarGetDayHistory',
    gwDayVO: 'game.mechanics.clan_war.model.ClanWarDayValueObject',
    gwLogEntry: 'game.mechanics.clan_war.mediator.log.ClanWarLogEntry',
    gwLogWarEntry: 'game.mechanics.clan_war.mediator.log.ClanWarLogWarEntry',
    gwLogBattleEntry: 'game.mechanics.clan_war.mediator.log.ClanWarLogBattleEntry',
    gwLogPopupMediator: 'game.mechanics.clan_war.mediator.log.ClanWarLogPopupMediator',
    rpcCommandBase: 'game.command.rpc.RPCCommandBase',

    // Core GW / CoW screens used only to decide whether the helper should be visible.
    gwStartScreen: 'game.mechanics.clan_war.popup.start.ClanWarStartScreen',
    gwWarScreen: 'game.mechanics.clan_war.popup.war.ClanWarScreen',
    cowStartScreen: 'game.mechanics.cross_clan_war.popup.start.CrossClanWarStartScreenPopup',
    cowSelectModePopup: 'game.mechanics.cross_clan_war.popup.selectMode.CrossClanWarSelectModePopup',
    cowWarScreen: 'game.mechanics.cross_clan_war.popup.war.CrossClanWarScreen',
  });

  const CONTEXT = Object.freeze({
    GW: Object.freeze({
      kind: 'GW',
      label: 'GW',
      popupClass: CLASS.gwAttackPopup,
      mediatorClass: CLASS.gwAttackMediator,
      slotClass: CLASS.gwSlotVO,
      heroMechanic: 'clan_pvp',
      titanMechanic: 'clan_pvp_titan',
    }),
    COW: Object.freeze({
      kind: 'CoW',
      label: 'CoW',
      popupClass: CLASS.cowAttackPopup,
      mediatorClass: CLASS.cowAttackMediator,
      slotClass: CLASS.cowSlotVO,
      heroMechanic: 'clan_global_pvp',
      titanMechanic: 'clan_global_pvp_titan',
    }),
  });

  const classCache = new Map();
  const mechanicCache = new Map();
  const resolverCache = new WeakMap();

  let selectedMode = MODES.MAX;
  let latestSnapshot = null;
  let latestSnapshotSignature = '';
  let launchBusy = false;
  let popupManagerCache = null;
  let popupManagerCacheAt = 0;
  let patronReferenceView = null;
  let referenceReopenLauncher = null;
  let patronRequestToken = 0;
  let unitDescriptionStorageCache = null;
  let unitDescriptionLookupMethodCache = null;
  let cowCommandListCache = null;
  let gwCommandListCache = null;
  let commandManagerCache = null;
  let rpcCreatorCache = null;
  const battleReplayPromiseCache = new Map();
  let cowLogFieldCache = null;
  let mainPanelController = null;
  let activeDefenseSession = null;
  const unitIconSpecCache = new Map();
  const unitIconSpecPromiseCache = new Map();
  const imageSizeCache = new Map();
  let bannerDescriptionStorageCache = null;
  let bannerDescriptionLookupMethodCache = null;
  let inventoryAssetStorageCache = null;
  let bannerBodyTextureMethodCache = null;
  const warFlagDataUrlCache = new Map();
  const warFlagDataUrlPromiseCache = new Map();
  let absolutePatternCache = null;
  let allPatternCache = null;
  let patternAssetMapperMethodCache = null;
  const patternAtlasInfoCache = new WeakMap();
  const unitAtlasInfoCache = new WeakMap();
  const gwHistoricalLocationCache = new Map();
  let nativeCowSession = null;
  let nativeCowHooksInstalled = false;
  let defeatedDisplayStyle = 'cross';

  class HWCTError extends Error {
    constructor(code, detail = '') {
      super(detail ? `${code}: ${detail}` : code);
      this.name = 'HWCTError';
      this.code = code;
      this.detail = detail;
    }
  }

  function fail(code, detail = '') {
    throw new HWCTError(code, detail);
  }

  function log(...args) {
    console.log(`[HW CT ${VERSION}]`, ...args);
  }

  function warn(...args) {
    console.warn(`[HW CT ${VERSION}]`, ...args);
  }

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

  async function waitFor(getValue, { timeout = WAIT_TIMEOUT_MS, step = WAIT_STEP_MS, code = 'WAIT_TIMEOUT' } = {}) {
    const started = Date.now();
    let lastError = null;
    while (Date.now() - started <= timeout) {
      try {
        const value = getValue();
        if (value) return value;
      } catch (error) {
        lastError = error;
      }
      await sleep(step);
    }
    if (lastError) warn(code, lastError);
    fail(code);
  }

  function uniqueRefs(items) {
    return [...new Set(items.filter(Boolean))];
  }

  function clamp(value, min, max) {
    return Math.min(Math.max(value, min), max);
  }

  function getHaxeRoot() {
    const root = window.$haxe;
    if (!root || typeof root !== 'object') fail('HAXE_NOT_READY');
    return root;
  }

  function findClass(fullName, { optional = false } = {}) {
    if (classCache.has(fullName)) return classCache.get(fullName);
    const matches = Object.values(getHaxeRoot()).filter(
      value => typeof value === 'function' && value.j === fullName
    );
    if (matches.length === 1) {
      classCache.set(fullName, matches[0]);
      return matches[0];
    }
    if (optional && matches.length === 0) return null;
    if (matches.length === 0) fail('CLASS_NOT_FOUND', fullName);
    fail('CLASS_AMBIGUOUS', `${fullName} (${matches.length})`);
  }

  function findSemanticKey(properties, semanticName) {
    if (!properties) return null;
    // Haxe __properties__ may inherit keys. for...in intentionally includes inherited entries.
    for (const key in properties) {
      if (properties[key] === semanticName) return key;
    }
    return null;
  }

  function findGetter(ClassObject, semanticName, { isStatic = false, optional = false } = {}) {
    const properties = isStatic
      ? ClassObject?.__properties__
      : ClassObject?.prototype?.__properties__;
    const key = findSemanticKey(properties, semanticName);
    if (key) return key;
    if (optional) return null;
    fail('GETTER_NOT_FOUND', `${ClassObject?.j ?? '(unknown class)'} -> ${semanticName}`);
  }

  function findSetter(ClassObject, semanticName, { isStatic = false, optional = false } = {}) {
    return findGetter(ClassObject, semanticName, { isStatic, optional });
  }

  function callSemantic(obj, semanticName, { optional = false } = {}) {
    if (!obj?.__class__) {
      if (optional) return undefined;
      fail('OBJECT_CLASS_MISSING', semanticName);
    }
    const method = findGetter(obj.__class__, semanticName, { optional });
    if (!method) return undefined;
    if (typeof obj[method] !== 'function') {
      if (optional) return undefined;
      fail('SEMANTIC_METHOD_MISSING', `${obj.__class__.j} -> ${semanticName}`);
    }
    return obj[method]();
  }

  function setSemantic(obj, semanticName, value) {
    if (!obj?.__class__) fail('OBJECT_CLASS_MISSING', semanticName);
    const method = findSetter(obj.__class__, semanticName);
    if (typeof obj[method] !== 'function') fail('SEMANTIC_METHOD_MISSING', semanticName);
    return obj[method](value);
  }

  function getPopupManager() {
    const Manager = findClass(CLASS.popupManager);
    const getInstance = findGetter(Manager, 'get_instance', { isStatic: true });
    let manager = null;
    try {
      manager = Manager[getInstance]?.();
    } catch (error) {
      warn('POPUP_MANAGER_GETTER_FAILED', error);
    }

    if (manager) {
      popupManagerCache = manager;
      popupManagerCacheAt = Date.now();
      return manager;
    }

    // GamePopupManager can be transiently null while the game is rebuilding popup state.
    // Reuse only a very recent valid instance so a click is not lost during that short gap.
    if (popupManagerCache?.__class__ === Manager && Date.now() - popupManagerCacheAt <= 2000) {
      return popupManagerCache;
    }

    fail('POPUP_MANAGER_NOT_FOUND');
  }

  function getOpenPopupsByClass(fullName) {
    const manager = getPopupManager();
    return uniqueRefs(
      Object.values(manager)
        .filter(Array.isArray)
        .flat()
        .filter(value => value?.__class__?.j === fullName)
    );
  }

  function hasOpenDemoBattle() {
    try {
      return getOpenPopupsByClass(CLASS.demoPopup).length > 0;
    } catch {
      return false;
    }
  }


  function hasOpenDefenseEditor() {
    try {
      return getOpenPopupsByClass(CLASS.demoDefenseGatherPopup).length > 0;
    } catch {
      return false;
    }
  }

  function hasOpenAttackEditor() {
    try {
      return getOpenPopupsByClass(CLASS.demoAttackGatherPopup).length > 0;
    } catch {
      return false;
    }
  }

  function hasOpenTrainingUi() {
    return hasOpenDemoBattle() || hasOpenDefenseEditor() || hasOpenAttackEditor();
  }

  function getBattleTeamLength(team) {
    if (!team || className(team) !== CLASS.battleTeam) return 0;
    try {
      const getter = findGetter(team.__class__, 'get_length');
      const value = Number(team[getter]?.());
      return Number.isFinite(value) ? value : 0;
    } catch {
      return 0;
    }
  }

  function getOwnBattleTeamRows(demoMediator) {
    const rows = [];
    if (!demoMediator || (typeof demoMediator !== 'object' && typeof demoMediator !== 'function')) return rows;

    for (const [field, value] of Object.entries(demoMediator)) {
      if (className(value) !== CLASS.battleTeam) continue;
      rows.push({
        field,
        team: value,
        length: getBattleTeamLength(value),
      });
    }
    return rows;
  }

  function captureBattleLaunchBaseline(demoMediator) {
    const rows = getOwnBattleTeamRows(demoMediator);
    return {
      teamCount: rows.length,
      emptyCount: rows.filter(row => row.length === 0).length,
      positiveCount: rows.filter(row => row.length > 0).length,
    };
  }

  function hasBattleTeamCommitTransition(session) {
    const demo = session?.demoMediator;
    const baseline = session?.battleLaunchBaseline;
    if (!demo || !baseline || baseline.emptyCount < 1) return false;

    const rows = getOwnBattleTeamRows(demo);
    if (!rows.length) return false;

    const emptyCount = rows.filter(row => row.length === 0).length;
    const positiveCount = rows.filter(row => row.length > 0).length;

    // Verified in both GW and CoW (2026-08-13):
    //   before To battle: defense BattleTeam=5, attack BattleTeam=0
    //   immediately after To battle: defense BattleTeam=5, attack BattleTeam=5
    // We intentionally do not depend on obfuscated field names such as $Nc/b7.
    return (
      emptyCount < baseline.emptyCount &&
      positiveCount > baseline.positiveCount
    );
  }

  function hasOpenBattlePreloader() {
    try {
      return getOpenPopupsByClass(CLASS.battlePreloaderPopup).length > 0;
    } catch {
      return false;
    }
  }

  function hasOpenBattleView() {
    try {
      return getOpenPopupsByClass(CLASS.battleViewScreen).length > 0;
    } catch {
      return false;
    }
  }

  function isWarScopeActive() {
    if (!window.$haxe) return false;
    const classes = [
      CLASS.gwStartScreen,
      CLASS.gwWarScreen,
      CLASS.gwAttackPopup,
      CLASS.cowStartScreen,
      CLASS.cowSelectModePopup,
      CLASS.cowWarScreen,
      CLASS.cowAttackPopup,
    ];
    try {
      return classes.some(name => getOpenPopupsByClass(name).length > 0);
    } catch {
      return false;
    }
  }

  function getMediatorFromPopup(popup, mediatorClass) {
    const matches = uniqueRefs(
      Object.values(popup ?? {}).filter(value => value?.__class__?.j === mediatorClass)
    );
    if (matches.length === 1) return matches[0];
    if (matches.length === 0) fail('MEDIATOR_NOT_FOUND', mediatorClass);
    fail('MEDIATOR_AMBIGUOUS', `${mediatorClass} (${matches.length})`);
  }

  function getCollectionData(collection) {
    const getData = findGetter(collection.__class__, 'get_data');
    const data = collection[getData]?.();
    return Array.isArray(data) ? data : null;
  }

  function getSlotList(mediator, slotClass) {
    const matches = uniqueRefs(
      Object.values(mediator ?? {}).filter(value => {
        if (value?.__class__?.j !== CLASS.listCollection) return false;
        const data = getCollectionData(value);
        return data?.some(item => item?.__class__?.j === slotClass) ?? false;
      })
    );
    if (matches.length === 1) return matches[0];
    if (matches.length === 0) fail('SLOT_LIST_NOT_FOUND');
    fail('SLOT_LIST_AMBIGUOUS', String(matches.length));
  }

  function normalizeState(raw) {
    if (typeof raw === 'string') return raw;
    if (typeof raw?.state === 'string') return raw.state;
    try {
      if (typeof raw?.P === 'function') {
        const value = raw.P();
        if (typeof value === 'string') return value;
        if (typeof value?.state === 'string') return value.state;
      }
    } catch {}
    return 'unknown';
  }

  function getUserName(user) {
    if (!user?.__class__) return '';
    try {
      return String(callSemantic(user, 'get_nickname', { optional: true }) ?? '');
    } catch {
      return '';
    }
  }

  function getUserId(user) {
    if (!user?.__class__) return '';
    try {
      const value = callSemantic(user, 'get_id', { optional: true });
      return value == null ? '' : String(value);
    } catch {
      return '';
    }
  }

  function getTargetUser(context, slot) {
    if (context.kind === 'CoW') {
      return callSemantic(slot, 'get_user', { optional: true }) ?? null;
    }
    const defender = callSemantic(slot, 'get_defender', { optional: true });
    return defender ? (callSemantic(defender, 'get_user', { optional: true }) ?? null) : null;
  }

  function getGwActiveTeam(slot, team) {
    if (!Array.isArray(team) || !team.length) return [];
    const defender = callSemantic(slot, 'get_defender', { optional: true });
    if (!defender) return team.slice();
    const hp = callSemantic(defender, 'get_hpPercentState', { optional: true });
    if (!Array.isArray(hp) || hp.length !== team.length) return team.slice();
    return team.filter((_, index) => Number(hp[index]) > 0);
  }

  function getCoWHeroStateMap(slot, team) {
    if (!Array.isArray(team) || !team.length) return null;

    try {
      // CoW keeps the original team in CrossClanWarCurrentSlotVO.get_team().
      // The live defeated/alive state is held separately in the current slot defender state.
      const currentSlots = uniqueRefs(
        Object.values(slot).filter(value => className(value) === CLASS.cowCurrentSlot)
      );
      if (currentSlots.length !== 1) {
        warn('COW_CURRENT_SLOT_STATE_UNAVAILABLE', currentSlots.length);
        return null;
      }

      const defenderProperty = callSemantic(currentSlots[0], 'get_defender', { optional: true });
      if (!defenderProperty || typeof defenderProperty !== 'object') {
        warn('COW_DEFENDER_STATE_PROPERTY_UNAVAILABLE');
        return null;
      }

      const teamStates = uniqueRefs(
        Object.values(defenderProperty).filter(value => className(value) === CLASS.cowBattleTeamWithState)
      );
      if (teamStates.length !== 1 || !Array.isArray(teamStates[0].units)) {
        warn('COW_TEAM_STATE_UNAVAILABLE', teamStates.length);
        return null;
      }

      const expectedIds = new Set();
      for (const unit of team) {
        const id = Number(callSemantic(unit, 'get_id', { optional: true }));
        if (Number.isFinite(id)) expectedIds.add(id);
      }
      if (expectedIds.size !== team.length) {
        warn('COW_TEAM_ID_RESOLUTION_INCOMPLETE', `${expectedIds.size}/${team.length}`);
        return null;
      }

      const stateById = new Map();
      for (const pair of teamStates[0].units) {
        const unit = pair?.first;
        const state = pair?.second;
        if (!unit || !state) continue;
        const id = Number(callSemantic(unit, 'get_id', { optional: true }));
        if (!Number.isFinite(id) || !expectedIds.has(id)) continue;
        const hp = Number(state.hp);
        stateById.set(id, { hp, alive: Number.isFinite(hp) ? hp > 0 : true });
      }

      // Fail closed: never label a Hero defeated unless every original team Hero has live state.
      if (stateById.size !== expectedIds.size) {
        warn('COW_TEAM_STATE_INCOMPLETE', `${stateById.size}/${expectedIds.size}`);
        return null;
      }
      return stateById;
    } catch (error) {
      warn('COW_HERO_STATE_FALLBACK', error);
      return null;
    }
  }

  function getCoWActiveTeam(slot, team) {
    if (!Array.isArray(team) || !team.length) return [];
    const stateById = getCoWHeroStateMap(slot, team);
    if (!stateById) return team.slice();
    return team.filter(unit => {
      const id = Number(callSemantic(unit, 'get_id', { optional: true }));
      return stateById.get(id)?.alive !== false;
    });
  }

  function getGwSlotDescriptionId(slot) {
    if (!slot) return null;
    try {
      const desc = callSemantic(slot, 'get_desc', { optional: true });
      const id = desc ? Number(callSemantic(desc, 'get_id', { optional: true })) : NaN;
      return Number.isFinite(id) ? id : null;
    } catch {
      return null;
    }
  }

  function rememberGwSlotLocation(slot, building, slotNumber) {
    const id = getGwSlotDescriptionId(slot);
    const position = Number(slotNumber);
    const name = String(building ?? '').trim();
    if (!Number.isFinite(id) || !Number.isFinite(position) || !name) return;
    gwHistoricalLocationCache.set(id, {
      building: name,
      position: String(position),
    });
  }

  function resolveGwHistoricalLocation(slotId, target = null) {
    const id = Number(slotId);
    if (!Number.isFinite(id)) return null;

    const cached = gwHistoricalLocationCache.get(id);
    if (cached) return cached;

    // Safe positive mapping: if the historical global slotId is the same
    // description ID as the currently selected defense, we know its localized
    // Building + local position from the live slot VO.
    const originalSlot = target?.originalSlot ?? null;
    const currentId = getGwSlotDescriptionId(originalSlot);
    const building = String(target?.building ?? '').trim();
    const position = Number(target?.slotNumber);
    if (Number.isFinite(currentId) && currentId === id && building && Number.isFinite(position)) {
      const value = { building, position: String(position) };
      gwHistoricalLocationCache.set(id, value);
      return value;
    }

    return null;
  }

  function getSlotBuildingName(context, slot) {
    if (context?.kind === 'CoW') {
      const name = callSemantic(slot, 'get_fortificationName', { optional: true });
      if (name != null && String(name).trim()) return String(name).trim();
    }
    if (context?.kind === 'GW') {
      const fortification = callSemantic(slot, 'get_fortificationDesc', { optional: true });
      const name = fortification ? callSemantic(fortification, 'get_name', { optional: true }) : null;
      if (name != null && String(name).trim()) return String(name).trim();
    }
    return '';
  }

  function getSlotTeamKey(team) {
    if (!Array.isArray(team)) return '';
    return sortedIdKey(
      team
        .map(unit => Number(callSemantic(unit, 'get_id', { optional: true })))
        .filter(Number.isFinite)
    );
  }

  function getSlotRuntimeInfo(context, slot) {
    const slotNumber = Number(callSemantic(slot, 'get_slotNumber'));
    const state = normalizeState(callSemantic(slot, 'get_slotState'));
    const team = callSemantic(slot, 'get_team', { optional: true });
    const targetUser = getTargetUser(context, slot);
    const fullTeam = Array.isArray(team) ? team : [];
    const activeTeam = context.kind === 'GW'
      ? getGwActiveTeam(slot, fullTeam)
      : context.kind === 'CoW'
        ? getCoWActiveTeam(slot, fullTeam)
        : fullTeam.slice();
    const isMyTarget = Boolean(callSemantic(slot, 'get_isMyTarget', { optional: true }));
    const building = getSlotBuildingName(context, slot);
    if (context.kind === 'GW') rememberGwSlotLocation(slot, building, slotNumber);

    return {
      slot,
      slotNumber,
      state,
      team: Array.isArray(team) ? team : [],
      activeTeam,
      teamKey: getSlotTeamKey(Array.isArray(team) ? team : []),
      building,
      user: targetUser,
      userName: getUserName(targetUser),
      userId: getUserId(targetUser),
      isMyTarget,
      canLaunch: state === 'ready' && activeTeam.length > 0,
    };
  }

  function detectContextSnapshot() {
    if (!window.$haxe) return { kind: null, status: 'loading', slots: [] };

    const found = [];
    for (const config of [CONTEXT.GW, CONTEXT.COW]) {
      const popups = getOpenPopupsByClass(config.popupClass);
      if (popups.length > 1) fail('ATTACK_POPUP_AMBIGUOUS', `${config.label}: ${popups.length}`);
      if (popups.length === 1) {
        const popup = popups[0];
        const mediator = getMediatorFromPopup(popup, config.mediatorClass);
        const list = getSlotList(mediator, config.slotClass);
        const slots = (getCollectionData(list) ?? [])
          .filter(slot => slot?.__class__?.j === config.slotClass)
          .map(slot => getSlotRuntimeInfo(config, slot))
          .sort((a, b) => a.slotNumber - b.slotNumber);
        found.push({ ...config, popup, mediator, list, slots });
      }
    }

    if (found.length === 0) return { kind: null, status: 'noAttackPopup', slots: [] };
    if (found.length !== 1) fail('CONTEXT_AMBIGUOUS', found.map(x => x.label).join(', '));
    return { ...found[0], status: 'ready' };
  }

  function getSnapshotSignature(snapshot) {
    if (!snapshot?.kind) return `${snapshot?.status ?? 'none'}`;
    return JSON.stringify({
      kind: snapshot.kind,
      slots: snapshot.slots.map(slot => [
        slot.slotNumber,
        slot.state,
        slot.activeTeam.length,
        slot.userName,
        slot.userId,
        slot.building,
        slot.teamKey,
        slot.isMyTarget,
      ]),
    });
  }

  function resolveBattleMode(type) {
    if (mechanicCache.has(type)) return mechanicCache.get(type);
    const Mechanic = findClass(CLASS.mechanicDescription);
    const getType = findGetter(Mechanic, 'get_type');
    const matches = [];

    const inspect = value => {
      if (value?.__class__ !== Mechanic) return;
      try {
        if (value[getType]?.() === type) matches.push(value);
      } catch {}
    };

    for (const owner of Object.values(getHaxeRoot())) {
      inspect(owner);
      if (typeof owner === 'function' || (owner && typeof owner === 'object')) {
        try {
          for (const value of Object.values(owner)) inspect(value);
        } catch {}
      }
    }

    const unique = uniqueRefs(matches);
    if (unique.length === 1) {
      mechanicCache.set(type, unique[0]);
      return unique[0];
    }
    if (unique.length === 0) fail('BATTLE_MODE_NOT_FOUND', type);
    fail('BATTLE_MODE_AMBIGUOUS', `${type} (${unique.length})`);
  }

  function getCurrentBannerEntry(bannerVO) {
    if (!bannerVO) return null;
    const matches = uniqueRefs(
      Object.values(bannerVO).filter(value => value?.__class__?.j === CLASS.playerBannerEntry)
    );
    if (matches.length === 1) return matches[0];
    if (matches.length === 0) fail('BANNER_ENTRY_NOT_FOUND');
    fail('BANNER_ENTRY_AMBIGUOUS', String(matches.length));
  }

  function getIntMapFromBannerEntry(entry) {
    if (!entry) return null;
    const matches = uniqueRefs(
      Object.values(entry).filter(value => value?.__class__?.j === CLASS.intMap)
    );
    if (matches.length === 1) return matches[0];
    if (matches.length === 0) fail('PATTERN_MAP_NOT_FOUND');
    fail('PATTERN_MAP_AMBIGUOUS', String(matches.length));
  }

  function getIntMapBackingObject(map) {
    if (!map) return null;
    const candidates = Object.values(map).filter(value => {
      if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
      return Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null;
    });
    if (candidates.length === 1) return candidates[0];

    // Fallback for minified Haxe IntMap implementations: choose the plain object containing BannerStoneDescription values.
    const likely = candidates.filter(value => {
      const values = Object.values(value);
      return values.length === 0 || values.every(v => v?.__class__?.j === CLASS.bannerStoneDescription);
    });
    if (likely.length === 1) return likely[0];
    fail('INTMAP_BACKING_NOT_FOUND');
  }

  function getPatternEntries(map) {
    const backing = getIntMapBackingObject(map);
    return Object.entries(backing ?? {}).map(([slot, pattern]) => [Number(slot), pattern]);
  }

  function setIntMapValue(map, key, value) {
    if (typeof map?.set === 'function') {
      map.set(Number(key), value);
      return;
    }
    const backing = getIntMapBackingObject(map);
    backing[String(key)] = value;
  }

  function getAbsolutePatterns() {
    if (absolutePatternCache) return absolutePatternCache;
    const InventoryItemType = findClass(CLASS.inventoryItemType);
    const typeMatches = uniqueRefs(
      Object.values(InventoryItemType).filter(
        value => value && typeof value === 'object' && value.type === 'bannerStone'
      )
    );
    if (typeMatches.length !== 1) {
      fail(typeMatches.length ? 'BANNER_STONE_TYPE_AMBIGUOUS' : 'BANNER_STONE_TYPE_NOT_FOUND');
    }
    const storage = typeMatches[0].storage;
    if (storage?.__class__?.j !== CLASS.bannerStoneStorage) fail('BANNER_STONE_STORAGE_NOT_FOUND');

    const Stone = findClass(CLASS.bannerStoneDescription);
    const getAbsolute = findGetter(Stone, 'get_isAbsoluteColor');

    // IMPORTANT: do not probe the storage by invoking every zero-argument method.
    // v0.2.1 did that and could disturb the live Pattern selection model.
    // Resolve the single read-only "absolute color list" method by source inspection,
    // then invoke only that method. This is the runtime equivalent of the proven Vij().
    const matches = [];
    for (const name of Object.getOwnPropertyNames(storage.__class__.prototype)) {
      if (name === 'constructor') continue;
      const fn = storage.__class__.prototype[name];
      if (typeof fn !== 'function' || fn.length !== 0) continue;
      const source = Function.prototype.toString.call(fn);
      if (source.includes(`.${getAbsolute}()`) && source.includes('.push(')) {
        matches.push(fn);
      }
    }
    if (matches.length !== 1) fail('ABSOLUTE_PATTERN_LIST_NOT_FOUND', String(matches.length));

    const result = matches[0].call(storage);
    if (!Array.isArray(result) || result.length === 0) fail('ABSOLUTE_PATTERN_LIST_NOT_FOUND');
    if (!result.every(item => item?.__class__ === Stone && item[getAbsolute]?.() === true)) {
      fail('ABSOLUTE_PATTERN_LIST_NOT_FOUND');
    }
    absolutePatternCache = result;
    return absolutePatternCache;
  }

  function getBannerStoneStorage() {
    const InventoryItemType = findClass(CLASS.inventoryItemType);
    const typeMatches = uniqueRefs(
      Object.values(InventoryItemType).filter(
        value => value && typeof value === 'object' && value.type === 'bannerStone'
      )
    );
    if (typeMatches.length !== 1) {
      fail(typeMatches.length ? 'BANNER_STONE_TYPE_AMBIGUOUS' : 'BANNER_STONE_TYPE_NOT_FOUND');
    }
    const storage = typeMatches[0].storage;
    if (storage?.__class__?.j !== CLASS.bannerStoneStorage) fail('BANNER_STONE_STORAGE_NOT_FOUND');
    return storage;
  }

  function getAllPatternsReadOnly() {
    if (allPatternCache) return allPatternCache;

    const storage = getBannerStoneStorage();
    const Stone = findClass(CLASS.bannerStoneDescription);
    const found = new Set();
    const seen = new Set();

    // Read-only traversal only. Never invoke unknown storage methods.
    // BannerStoneDescriptionStorage contains the live description objects
    // reachable through its own enumerable containers.
    const walk = (value, depth) => {
      if (value == null || depth > 6) return;

      if (value?.__class__ === Stone) {
        found.add(value);
        return;
      }

      if (typeof value !== 'object' || seen.has(value)) return;
      seen.add(value);

      if (Array.isArray(value)) {
        for (const item of value) walk(item, depth + 1);
        return;
      }

      for (const [key, child] of Object.entries(value)) {
        if (key === '__class__' || typeof child === 'function') continue;
        walk(child, depth + 1);
      }
    };

    walk(storage, 0);

    const result = [...found];
    // Proven live storage contains 144 entries (12 Pattern types × 12 states).
    // Fail soft for display purposes if Hero Wars changes the storage layout.
    if (result.length < 12) {
      warn('ALL_PATTERN_READONLY_SCAN_INCOMPLETE', { count: result.length });
      return [];
    }

    allPatternCache = result;
    return allPatternCache;
  }

  function getPatternColorTier(pattern) {
    if (!pattern) return null;

    const all = getAllPatternsReadOnly();
    if (!all.length) return null;

    const typeKey = getPatternTypeKey(pattern);
    const sameType = all.filter(item => {
      try {
        return getPatternTypeKey(item) === typeKey;
      } catch {
        return false;
      }
    });

    if (sameType.length < 7) {
      warn('PATTERN_TIER_GROUP_INCOMPLETE', { typeKey, count: sameType.length });
      return null;
    }

    const distinctValues = [...new Set(
      sameType
        .map(item => getPatternBuffValue(item))
        .filter(value => Number.isFinite(value))
        .map(value => Math.abs(Number(value)))
        .map(value => Math.round(value * 1000000) / 1000000)
    )].sort((a, b) => a - b);

    const current = getPatternBuffValue(pattern);
    if (!Number.isFinite(current) || distinctValues.length < 7) return null;

    const currentAbs = Math.abs(Number(current));
    let index = distinctValues.findIndex(value => Math.abs(value - currentAbs) < 1e-6);

    // Very small float differences are possible in game data.
    if (index < 0) {
      let bestIndex = -1;
      let bestDiff = Infinity;
      for (let i = 0; i < distinctValues.length; i += 1) {
        const diff = Math.abs(distinctValues[i] - currentAbs);
        if (diff < bestDiff) {
          bestDiff = diff;
          bestIndex = i;
        }
      }
      if (bestDiff <= 0.001) index = bestIndex;
    }

    if (index < 0) return null;

    if (index >= 6) return 'ultimate';

    return [
      'white',
      'green',
      'blue',
      'violet',
      'orange',
      'red',
    ][index] ?? null;
  }

  function getPatternTypeKey(pattern) {
    if (!pattern) fail('PATTERN_TYPE_NOT_FOUND');

    // Older clients exposed the stable resource key directly as Nx. Keep it when
    // available, but do not depend on a minified field name. Current clients still
    // expose the localized Pattern name through the inherited semantic get_name
    // property; that name is identical across color tiers and is therefore a safe
    // same-client type key for Current -> Absolute MAX matching.
    if (pattern?.Nx != null && String(pattern.Nx).trim()) return `resource:${String(pattern.Nx).trim()}`;
    try {
      const semanticName = callSemantic(pattern, 'get_name', { optional: true });
      if (semanticName != null && String(semanticName).trim()) return `name:${String(semanticName).trim()}`;
    } catch {}
    for (const key of ['ri', 'si', 'name']) {
      const value = pattern?.[key];
      if (value != null && String(value).trim()) return `name:${String(value).trim()}`;
    }
    fail('PATTERN_TYPE_NOT_FOUND');
  }

  function buildMaxBanner(currentBanner) {
    if (!currentBanner) return null;
    const currentEntry = getCurrentBannerEntry(currentBanner);
    const currentMap = getIntMapFromBannerEntry(currentEntry);
    const currentEntries = getPatternEntries(currentMap);

    const MapClass = currentMap.__class__;
    const maxMap = new MapClass();

    if (currentEntries.length > 0) {
      const absolutePatterns = getAbsolutePatterns();
      for (const [slot, currentPattern] of currentEntries) {
        const typeKey = getPatternTypeKey(currentPattern);
        const matches = absolutePatterns.filter(pattern => getPatternTypeKey(pattern) === typeKey);
        if (matches.length !== 1) {
          fail('MAX_PATTERN_MATCH_FAILED', `${typeKey}: ${matches.length}`);
        }
        setIntMapValue(maxMap, slot, matches[0]);
      }
    }

    const EntryClass = currentEntry.__class__;
    const bannerDesc = callSemantic(currentEntry, 'get_desc');
    const maxEntry = new EntryClass(bannerDesc, maxMap);
    const BannerVOClass = currentBanner.__class__;
    const maxBanner = new BannerVOClass(bannerDesc, maxEntry, null, false);

    // Fail closed: verify that the original and clone are distinct.
    if (getCurrentBannerEntry(maxBanner) === currentEntry) fail('BANNER_CLONE_FAILED');
    return maxBanner;
  }

  function getCoWBuffs(mediator) {
    const buffProviders = uniqueRefs(
      Object.values(mediator ?? {}).filter(value => value?.__class__?.j === CLASS.cowAttackBuffVO)
    );
    if (buffProviders.length > 1) fail('COW_BUFF_AMBIGUOUS', String(buffProviders.length));
    if (buffProviders.length === 0) return [];
    const buff = callSemantic(buffProviders[0], 'get_buff', { optional: true });
    return buff ? [buff] : [];
  }

  function resolveDefenderOverrideField(DemoClass) {
    let cached = resolverCache.get(DemoClass);
    if (!cached) {
      cached = {};
      resolverCache.set(DemoClass, cached);
    }
    if (cached.defenderField) return cached.defenderField;

    const Command = findClass(CLASS.commandDemoBattleStart);
    const setDefender = findSetter(Command, 'set_defender');
    const setAttacker = findSetter(Command, 'set_attacker');

    const candidates = Object.entries(DemoClass.prototype).filter(([, fn]) => {
      if (typeof fn !== 'function') return false;
      const source = Function.prototype.toString.call(fn);
      return source.includes(`.${setDefender}(`) && source.includes(`.${setAttacker}(`);
    });
    if (candidates.length !== 1) fail('START_METHOD_NOT_FOUND', String(candidates.length));

    const source = Function.prototype.toString.call(candidates[0][1]);
    const escaped = setDefender.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
    const regex = new RegExp(`\\.${escaped}\\(null!=this\\.([A-Za-z_$][\\w$]*)\\?this\\.\\1:`);
    const match = source.match(regex);
    if (!match) fail('DEFENDER_FIELD_NOT_FOUND');

    cached.startMethod = candidates[0][0];
    cached.defenderField = match[1];
    return cached.defenderField;
  }

  function setDefenderUser(demo, user) {
    if (!user) fail('TARGET_USER_NOT_FOUND');
    const field = resolveDefenderOverrideField(demo.__class__);
    demo[field] = user;
  }

  function getOptionSelectedProperty(option) {
    const matches = uniqueRefs(
      Object.values(option ?? {}).filter(
        value => value?.__class__?.j === CLASS.booleanPropertyWriteable
      )
    );
    if (matches.length === 1) return matches[0];
    if (matches.length === 0) fail('POWER_OPTION_SELECTED_PROP_NOT_FOUND');
    fail('POWER_OPTION_SELECTED_PROP_AMBIGUOUS', String(matches.length));
  }

  function setOptionSelected(option, selected) {
    const property = getOptionSelectedProperty(option);
    setSemantic(property, 'set_value', Boolean(selected));
  }

  function resolvePowerControl(demo, semanticGetter) {
    const DemoClass = demo.__class__;
    const getterName = findGetter(DemoClass, semanticGetter);
    const getterSource = Function.prototype.toString.call(DemoClass.prototype[getterName]);
    const fieldMatch = getterSource.match(/this\.([A-Za-z_$][\w$]*)\s*==/);
    if (!fieldMatch) fail('POWER_FIELD_NOT_FOUND', semanticGetter);
    const valueField = fieldMatch[1];

    const selectorMatches = Object.entries(DemoClass.prototype).filter(([, fn]) => {
      if (typeof fn !== 'function' || fn.length !== 1) return false;
      const source = Function.prototype.toString.call(fn);
      return source.includes(`this.${valueField}=`);
    });
    if (selectorMatches.length !== 1) fail('POWER_SELECTOR_NOT_FOUND', `${semanticGetter}: ${selectorMatches.length}`);

    const selectorSource = Function.prototype.toString.call(selectorMatches[0][1]);
    const listFields = Object.keys(demo).filter(key => {
      const value = demo[key];
      return value?.__class__?.j === CLASS.listCollection && selectorSource.includes(`this.${key}.`);
    });
    if (listFields.length !== 1) fail('POWER_LIST_NOT_FOUND', `${semanticGetter}: ${listFields.length}`);

    const collection = demo[listFields[0]];
    const options = getCollectionData(collection) ?? [];
    if (options.length !== 2) fail('POWER_OPTIONS_INVALID', `${semanticGetter}: ${options.length}`);

    return {
      getterName,
      valueField,
      selector: selectorMatches[0][1],
      collection,
      currentOption: options[0],
      maxOption: options[1],
    };
  }

  function selectPowerOption(demo, control, useMax) {
    const option = useMax ? control.maxOption : control.currentOption;
    control.selector.call(demo, option);
    setOptionSelected(control.currentOption, !useMax);
    setOptionSelected(control.maxOption, useMax);
  }

  function setInternalCurrentWithoutChangingView(demo, control) {
    demo[control.valueField] = control.currentOption.type;
  }

  function findPresetApplyMethod(DemoClass) {
    let cached = resolverCache.get(DemoClass);
    if (!cached) {
      cached = {};
      resolverCache.set(DemoClass, cached);
    }
    if (cached.applyPreset) return cached.applyPreset;

    const globalGetter = findGetter(DemoClass, 'get_isClanGlobalPvpPreset');
    const matches = Object.values(DemoClass.prototype).filter(fn => {
      if (typeof fn !== 'function' || fn.length !== 0) return false;
      const source = Function.prototype.toString.call(fn);
      return source.includes(`this.${globalGetter}()`) &&
        source.includes('this.player') &&
        source.includes('"hero"') &&
        source.includes('"pet"');
    });
    if (matches.length !== 1) fail('PRESET_APPLY_METHOD_NOT_FOUND', String(matches.length));
    cached.globalPresetGetter = globalGetter;
    cached.applyPreset = matches[0];
    return matches[0];
  }

  function getGlobalPresetGetter(DemoClass) {
    let cached = resolverCache.get(DemoClass);
    if (cached?.globalPresetGetter) return cached.globalPresetGetter;
    findPresetApplyMethod(DemoClass);
    return resolverCache.get(DemoClass).globalPresetGetter;
  }

  function getDefenseBattleTeam(demo, expectedCount) {
    const BattleTeam = findClass(CLASS.battleTeam);
    const getLength = findGetter(BattleTeam, 'get_length');
    const matches = uniqueRefs(
      Object.values(demo).filter(value =>
        value?.__class__ === BattleTeam &&
        typeof value[getLength] === 'function' &&
        value[getLength]() === expectedCount
      )
    );
    if (matches.length === 1) return matches[0];
    if (matches.length > 1) fail('DEFENSE_TEAM_AMBIGUOUS', String(matches.length));
    return null;
  }

  function setBattleTeamBanner(team, banner) {
    if (!banner) return;
    const setBanner = findSetter(team.__class__, 'set_bannerVO');
    team[setBanner](banner);
  }

  async function forceGwMaxDisplay(demo, expectedCount) {
    const DemoClass = demo.__class__;
    const globalGetter = getGlobalPresetGetter(DemoClass);
    const applyPreset = findPresetApplyMethod(DemoClass);

    // Select Maximum once. In the current Hero Wars build the popup can already
    // be visible while the GW preset internals are still initializing.
    const control = resolvePowerControl(demo, 'get_defenderMaxPowerMode');
    selectPowerOption(demo, control, true);

    const applyNativePresetOnce = () => {
      const hadOwn = Object.prototype.hasOwnProperty.call(demo, globalGetter);
      const oldMethod = demo[globalGetter];
      demo[globalGetter] = () => true;

      try {
        applyPreset.call(demo);
      } finally {
        if (hadOwn) demo[globalGetter] = oldMethod;
        else delete demo[globalGetter];
      }
    };

    // Live verification on 2026-08-13 showed:
    // - calling the native GW preset too early throws
    //   "Cannot read properties of undefined (reading 'length')"
    // - the exact same popup succeeds when the same native method is called later.
    //
    // Treat only that specific TypeError as a transient "popup not ready yet"
    // condition. Do not hide any other failure.
    const deadline = Date.now() + 3000;
    let transientError = null;
    let attempts = 0;

    while (true) {
      attempts += 1;

      try {
        applyNativePresetOnce();
        break;
      } catch (error) {
        const message = String(error?.message ?? error ?? '');
        const isTransientLengthError =
          /Cannot read properties of undefined.*reading ['"]length['"]/i.test(message) ||
          /undefined.*length/i.test(message);

        if (!isTransientLengthError) throw error;

        transientError = error;

        if (Date.now() >= deadline) {
          fail('GW_PRESET_READY_TIMEOUT', message);
        }

        await sleep(100);
      }
    }

    const team = await waitFor(
      () => getDefenseBattleTeam(demo, expectedCount),
      { code: 'GW_MAX_TEAM_TIMEOUT' }
    );

    const teamSignalGetter = findGetter(demo.__class__, 'get_signal_defenderTeamChange');
    const teamSignal = demo[teamSignalGetter]?.();
    if (typeof teamSignal?.S === 'function') teamSignal.S();

    log('GW MAX preset ready', {
      attempts,
      teamCount: expectedCount,
      transientRetry: Boolean(transientError),
    });

    return { team, control };
  }

  async function waitForCoWMaxDisplay(demo, expectedCount) {
    const team = await waitFor(
      () => getDefenseBattleTeam(demo, expectedCount),
      { code: 'COW_MAX_TEAM_TIMEOUT' }
    );
    const control = resolvePowerControl(demo, 'get_defenderMaxPowerMode');
    if (!demo[control.getterName]()) fail('COW_MAX_NOT_ACTIVE');
    return { team, control };
  }

  function getLaunchData(snapshot, slotNumber) {
    if (!snapshot?.kind || !snapshot?.mediator) fail('ATTACK_CONTEXT_NOT_FOUND');
    const previousItem = snapshot.slots.find(slot => slot.slotNumber === slotNumber);
    if (!previousItem) fail('SLOT_NOT_FOUND', String(slotNumber));

    // Re-read the clicked slot from the live VO immediately before launching.
    // This keeps state/HP current without requiring the PopupManager to be resolved again.
    const item = getSlotRuntimeInfo(snapshot, previousItem.slot);
    if (item.state !== 'ready') fail('SLOT_NOT_READY', item.state);
    if (!item.activeTeam.length) fail('SLOT_EMPTY', String(slotNumber));

    const slot = item.slot;
    const team = item.activeTeam.slice();
    const pet = callSemantic(slot, 'get_pet', { optional: true }) ?? null;
    const banner = callSemantic(slot, 'get_banner', { optional: true }) ?? null;
    const isHero = Boolean(callSemantic(slot, 'get_isHeroSlot'));
    const desc = callSemantic(slot, 'get_desc');
    const descId = callSemantic(desc, 'get_id');
    const targetUser = getTargetUser(snapshot, slot);
    const buffs = snapshot.kind === 'CoW' ? getCoWBuffs(snapshot.mediator) : [];
    const mechanicType = isHero ? snapshot.heroMechanic : snapshot.titanMechanic;
    const battleMode = resolveBattleMode(mechanicType);

    return {
      snapshot,
      item,
      slot,
      team,
      pet,
      banner,
      isHero,
      descId,
      targetUser,
      buffs,
      battleMode,
    };
  }


  function resolveNativeCowTarget(demoMediator) {
    const popups = getOpenPopupsByClass(CLASS.cowAttackPopup);
    if (popups.length !== 1) return null;
    const popup = popups[0];
    const mediator = getMediatorFromPopup(popup, CLASS.cowAttackMediator);
    const list = getSlotList(mediator, CLASS.cowSlotVO);
    const slots = (getCollectionData(list) ?? []).filter(slot => className(slot) === CLASS.cowSlotVO);
    if (!slots.length) return null;

    const presetMatches = uniqueRefs(
      Object.values(demoMediator ?? {}).filter(value => className(value) === CLASS.demoPresets)
    );
    if (presetMatches.length !== 1) return null;
    const preset = presetMatches[0];

    const unitClasses = new Set(
      slots.flatMap(slot => {
        const team = callSemantic(slot, 'get_team', { optional: true });
        return Array.isArray(team) ? team : [];
      }).map(className).filter(Boolean)
    );
    const presetTeams = Object.values(preset).filter(value =>
      Array.isArray(value) && value.length > 0 && value.every(unit => unitClasses.has(className(unit)))
    );
    if (presetTeams.length !== 1) return null;
    const presetTeam = presetTeams[0];

    const matches = slots.map((slot, index) => {
      const team = callSemantic(slot, 'get_team', { optional: true });
      const fullTeam = Array.isArray(team) ? team : [];
      const exact = fullTeam.length === presetTeam.length && presetTeam.every(unit => fullTeam.includes(unit));
      return { slot, index, fullTeam, exact };
    }).filter(row => row.exact);
    if (matches.length !== 1) return null;

    const match = matches[0];
    if (!Boolean(callSemantic(match.slot, 'get_isHeroSlot', { optional: true }))) return null;
    const semanticSlotNumber = Number(callSemantic(match.slot, 'get_slotNumber', { optional: true }));
    const listPosition = match.index + 1;
    const target = buildPatronTargetFromSlot('CoW', match.slot, {
      slotNumber: Number.isFinite(semanticSlotNumber) ? semanticSlotNumber : listPosition,
      listPosition,
      building: getSlotBuildingName(CONTEXT.COW, match.slot),
    });
    if (!target) return null;
    return { popup, mediator, list, slot: match.slot, listPosition, preset, presetTeam, target };
  }

  function isReferenceSessionActive(session) {
    if (!session) return false;
    if (session === nativeCowSession) return true;
    return Boolean(activeDefenseSession && activeDefenseSession.key === session.key);
  }

  function handleNativeCowDemoOpened(demoMediator, demoPopup) {
    let resolved = null;
    try { resolved = resolveNativeCowTarget(demoMediator); }
    catch (error) { warn('COW_NATIVE_TARGET_RESOLVE_FAILED', error); }
    if (!resolved?.target) return;

    hidePatronReference({ restoreMain: false });
    const target = resolved.target;
    nativeCowSession = {
      key: ['CoW', target.listPosition ?? target.slotNumber ?? '', target.playerId ?? '', target.heroKey ?? ''].join('|'),
      popup: demoPopup,
      demoMediator,
      battleLaunchBaseline: captureBattleLaunchBaseline(demoMediator),
      attackEditorSeen: false,
      target,
      referenceResult: null,
      referenceError: null,
      referenceLoading: false,
    };
    log('CoW native target', {
      position: target.listPosition,
      player: target.playerName,
      heroKey: target.heroKey,
    });
    startPatronReferenceLoad(target, nativeCowSession);
  }

  function handleNativeCowDemoDisposed(demoPopup) {
    if (!nativeCowSession || nativeCowSession.popup !== demoPopup) return;
    nativeCowSession = null;
    hidePatronReference({ restoreMain: false });
  }

  function installNativeCowHooksOnce() {
    if (nativeCowHooksInstalled) return true;
    try {
      const Demo = findClass(CLASS.demoMediator);
      const Popup = findClass(CLASS.demoPopup);
      const createName = 'createPopup';
      const disposeName = 'dispose';
      if (typeof Demo.prototype?.[createName] !== 'function' || typeof Popup.prototype?.[disposeName] !== 'function') return false;

      if (!Demo.prototype.__hwctV04CreateOriginal) {
        const originalCreate = Demo.prototype[createName];
        Object.defineProperty(Demo.prototype, '__hwctV04CreateOriginal', { value: originalCreate, configurable: true });
        Demo.prototype[createName] = function (...args) {
          const result = originalCreate.apply(this, args);
          try { handleNativeCowDemoOpened(this, result); }
          catch (error) { warn('COW_NATIVE_OPEN_HOOK_FAILED', error); }
          return result;
        };
      }

      if (!Popup.prototype.__hwctV04DisposeOriginal) {
        const originalDispose = Popup.prototype[disposeName];
        Object.defineProperty(Popup.prototype, '__hwctV04DisposeOriginal', { value: originalDispose, configurable: true });
        Popup.prototype[disposeName] = function (...args) {
          const shouldClose = nativeCowSession?.popup === this;
          try { return originalDispose.apply(this, args); }
          finally {
            if (shouldClose) {
              try { handleNativeCowDemoDisposed(this); }
              catch (error) { warn('COW_NATIVE_CLOSE_HOOK_FAILED', error); }
            }
          }
        };
      }

      nativeCowHooksInstalled = true;
      log('native CoW hooks installed');
      return true;
    } catch (error) {
      if (error?.code !== 'HAXE_NOT_READY' && error?.code !== 'CLASS_NOT_FOUND') warn('COW_NATIVE_HOOK_INSTALL_RETRY', error);
      return false;
    }
  }

  // ---------------------------------------------------------------------------
  // Patron Reference (GW / CoW real battle logs only)
  // ---------------------------------------------------------------------------

  function className(value) {
    return value?.__class__?.j ?? null;
  }

  function prototypeMethodsDeep(valueOrClass) {
    const start = typeof valueOrClass === 'function'
      ? valueOrClass.prototype
      : valueOrClass?.__class__?.prototype ?? Object.getPrototypeOf(valueOrClass ?? null);
    const rows = [];
    const seen = new Set();
    let proto = start;
    while (proto && proto !== Object.prototype) {
      for (const name of Object.getOwnPropertyNames(proto)) {
        if (name === 'constructor' || seen.has(name)) continue;
        const fn = proto[name];
        if (typeof fn !== 'function') continue;
        seen.add(name);
        rows.push({ name, fn, source: String(fn) });
      }
      proto = Object.getPrototypeOf(proto);
    }
    return rows;
  }

  function findMethodBySource(valueOrClass, predicate, code) {
    const matches = prototypeMethodsDeep(valueOrClass).filter(row => {
      try { return predicate(row.source, row.fn, row.name); } catch { return false; }
    });
    if (matches.length === 1) return matches[0].name;
    if (matches.length === 0) fail(code ?? 'SOURCE_METHOD_NOT_FOUND');
    fail(`${code ?? 'SOURCE_METHOD'}_AMBIGUOUS`, String(matches.length));
  }

  function sortedIdKey(ids) {
    return ids.map(Number).filter(Number.isFinite).sort((a, b) => a - b).join(',');
  }

  function getClanInfo(user) {
    return callSemantic(user, 'get_clanInfo', { optional: true }) ?? null;
  }

  function getClanId(clan) {
    const value = clan ? callSemantic(clan, 'get_id', { optional: true }) : null;
    return value == null ? '' : String(value);
  }

  function getClanTitle(clan) {
    const value = clan ? callSemantic(clan, 'get_title', { optional: true }) : null;
    return value == null ? '' : String(value);
  }

  function buildPatronTargetFromSlot(kind, slot, {
    slotNumber = null,
    listPosition = null,
    building = '',
  } = {}) {
    if (kind !== 'GW' && kind !== 'CoW') return null;
    if (!slot || !Boolean(callSemantic(slot, 'get_isHeroSlot', { optional: true }))) return null;

    const context = kind === 'GW' ? CONTEXT.GW : CONTEXT.COW;
    const user = getTargetUser(context, slot);
    const clan = getClanInfo(user);
    const team = callSemantic(slot, 'get_team', { optional: true });
    const fullTeam = Array.isArray(team) ? team : [];
    const heroIds = fullTeam
      .map(hero => Number(callSemantic(hero, 'get_id', { optional: true })))
      .filter(Number.isFinite);
    if (heroIds.length < 1 || heroIds.length > 5 || heroIds.length !== fullTeam.length) return null;

    const bannerVO = callSemantic(slot, 'get_banner', { optional: true }) ?? null;
    let currentBannerId = null;
    if (bannerVO) {
      try {
        const entry = getCurrentBannerEntry(bannerVO);
        const desc = entry ? callSemantic(entry, 'get_desc', { optional: true }) : null;
        const id = desc ? Number(callSemantic(desc, 'get_id', { optional: true })) : NaN;
        if (Number.isFinite(id)) currentBannerId = id;
      } catch (error) {
        warn('PATRON_BANNER_ID_SKIPPED', error);
      }
    }

    const resolvedSlotNumber = slotNumber == null ? NaN : Number(slotNumber);
    const resolvedListPosition = listPosition == null ? NaN : Number(listPosition);
    const target = {
      kind,
      slotNumber: Number.isFinite(resolvedSlotNumber)
        ? resolvedSlotNumber
        : (Number.isFinite(resolvedListPosition) ? resolvedListPosition : null),
      listPosition: Number.isFinite(resolvedListPosition) ? resolvedListPosition : null,
      building: building || getSlotBuildingName(context, slot),
      guildId: getClanId(clan),
      guild: getClanTitle(clan),
      playerId: getUserId(user),
      playerName: getUserName(user),
      heroIds,
      heroKey: sortedIdKey(heroIds),
      currentBannerId,
      flagIconDataUrl: null,
      originalSlot: slot,
      heroStateById: kind === 'CoW' ? getCoWHeroStateMap(slot, fullTeam) : null,
      currentDefense: null,
    };

    try {
      target.currentDefense = buildCurrentDefenseReference(slot);
    } catch (error) {
      warn('CURRENT_DEFENSE_REFERENCE_SKIPPED', error);
    }
    return target;
  }

  function getPatronTarget(snapshot, item) {
    if (snapshot?.kind !== 'GW' && snapshot?.kind !== 'CoW') return null;
    const slot = item?.slot;
    if (!slot) return null;
    return buildPatronTargetFromSlot(snapshot.kind, slot, {
      slotNumber: item.slotNumber,
      building: item.building || getSlotBuildingName(snapshot, slot),
    });
  }

  function getCommandManager() {
    if (commandManagerCache?.__class__?.j === CLASS.commandManager) return commandManagerCache;
    const GameModel = findClass(CLASS.gameModel);
    const getInstance = findGetter(GameModel, 'get_instance', { isStatic: true });
    const game = GameModel[getInstance]?.();
    if (!game) fail('GAME_MODEL_NOT_FOUND');
    const managers = uniqueRefs(Object.values(game).filter(value => className(value) === CLASS.commandManager));
    if (managers.length !== 1) fail(managers.length ? 'COMMAND_MANAGER_AMBIGUOUS' : 'COMMAND_MANAGER_NOT_FOUND');
    commandManagerCache = managers[0];
    return commandManagerCache;
  }

  function getCowCommandList() {
    if (cowCommandListCache?.__class__?.j === CLASS.cowCommandList) return cowCommandListCache;
    const manager = getCommandManager();
    const lists = uniqueRefs(Object.values(manager).filter(value => className(value) === CLASS.cowCommandList));
    if (lists.length !== 1) fail(lists.length ? 'COW_COMMAND_LIST_AMBIGUOUS' : 'COW_COMMAND_LIST_NOT_FOUND');
    cowCommandListCache = lists[0];
    return lists[0];
  }

  function getGwCommandList() {
    if (gwCommandListCache?.__class__?.j === CLASS.gwCommandList) return gwCommandListCache;
    const manager = getCommandManager();
    const lists = uniqueRefs(Object.values(manager).filter(value => className(value) === CLASS.gwCommandList));
    if (lists.length !== 1) fail(lists.length ? 'GW_COMMAND_LIST_AMBIGUOUS' : 'GW_COMMAND_LIST_NOT_FOUND');
    gwCommandListCache = lists[0];
    return lists[0];
  }

  function getRpcCreator() {
    if (rpcCreatorCache?.__class__?.j === CLASS.rpcCreator) return rpcCreatorCache;
    const manager = getCommandManager();
    const creators = uniqueRefs(Object.values(manager).filter(value => className(value) === CLASS.rpcCreator));
    if (creators.length !== 1) fail(creators.length ? 'RPC_CREATOR_AMBIGUOUS' : 'RPC_CREATOR_NOT_FOUND');
    rpcCreatorCache = creators[0];
    return rpcCreatorCache;
  }

  function getRpcPromiseMethod(rpcCreator) {
    return findMethodBySource(
      rpcCreator,
      (source, fn) => fn.length === 3 && source.includes('this.create(') && source.includes('this.uVe('),
      'RPC_CREATOR_PROMISE_METHOD_NOT_FOUND'
    );
  }

  function getCowBattleReplayLoader() {
    const Mediator = findClass(CLASS.cowLogBattlePopupMediator);
    const method = findMethodBySource(
      Mediator,
      source => source.includes('battleGetReplay') && source.includes('replay'),
      'COW_BATTLE_REPLAY_LOADER_NOT_FOUND'
    );

    const fn = Mediator.prototype?.[method];
    if (typeof fn !== 'function') fail('COW_BATTLE_REPLAY_LOADER_INVALID');

    // Current game build's loader does not depend on a live popup/mediator instance.
    // It creates the game's own internal request map, calls battleGetReplay, and stores
    // replayRawData on the supplied BattleVO. Fail closed if a future build changes that.
    if (/\bthis\./.test(String(fn))) fail('COW_BATTLE_REPLAY_LOADER_INSTANCE_REQUIRED');

    return { Mediator, method, fn };
  }

  async function loadBattleReplayForVo(vo) {
    if (!vo) fail('BATTLE_VO_MISSING');
    const id = String(callSemantic(vo, 'get_replayId', { optional: true }) ?? '');
    if (!id) fail('BATTLE_REPLAY_ID_MISSING');

    const existing = callSemantic(vo, 'get_replayRawData', { optional: true });
    if (existing) return existing;
    if (battleReplayPromiseCache.has(id)) return battleReplayPromiseCache.get(id);

    const promise = (async () => {
      const { Mediator, fn } = getCowBattleReplayLoader();
      const pending = fn.call(Mediator.prototype, vo);
      if (pending && typeof pending.then === 'function') await pending;

      const replay = callSemantic(vo, 'get_replayRawData', { optional: true });
      if (!replay) fail('BATTLE_REPLAY_EMPTY', id);
      return replay;
    })();

    battleReplayPromiseCache.set(id, promise);
    try {
      return await promise;
    } catch (error) {
      battleReplayPromiseCache.delete(id);
      throw error;
    }
  }

  function getCowCommandMethods(commandList) {
    const available = findMethodBySource(
      commandList,
      source => source.includes('crossClanWar_getAvailableHistory'),
      'COW_AVAILABLE_HISTORY_METHOD_NOT_FOUND'
    );
    const history = findMethodBySource(
      commandList,
      source => source.includes('crossClanWar_getWarHistory'),
      'COW_WAR_HISTORY_METHOD_NOT_FOUND'
    );
    return { available, history };
  }

  function resolveCowLogFields() {
    if (cowLogFieldCache) return cowLogFieldCache;
    const LogItem = findClass(CLASS.cowLogItem);
    const source = String(LogItem);
    const signature = source.match(/function[^\(]*\(([^)]*)\)/);
    if (!signature) fail('COW_LOGITEM_SIGNATURE_NOT_FOUND');
    const params = signature[1].split(',').map(x => x.trim()).filter(Boolean);
    if (params.length < 3) fail('COW_LOGITEM_PARAMS_TOO_SHORT', String(params.length));
    const fieldFromParam = param => {
      const re = new RegExp(`this\\.([A-Za-z_$][\\w$]*)\\s*=\\s*${param.replace(/[$]/g, '\\$&')}(?=[;,}])`);
      return source.match(re)?.[1] ?? null;
    };
    const seasonField = fieldFromParam(params[1]);
    const warField = fieldFromParam(params[2]);
    if (!seasonField || !warField) fail('COW_LOGITEM_FIELDS_NOT_FOUND');

    let timestampField = null;
    try {
      const WarVO = findClass(CLASS.cowLogWarVO);
      const dateGetter = findGetter(WarVO, 'get_date');
      const dateSource = String(WarVO.prototype[dateGetter]);
      timestampField = dateSource.match(/1E3\s*\*\s*this\.[A-Za-z_$][\w$]*\.([A-Za-z_$][\w$]*)/)?.[1] ?? null;
    } catch (error) {
      warn('COW_LOG_TIMESTAMP_FIELD_FALLBACK', error);
    }

    cowLogFieldCache = { seasonField, warField, timestampField };
    return cowLogFieldCache;
  }

  function getWarEnemyClan(war) {
    return Object.values(war ?? {}).find(value => className(value) === CLASS.clanBasicInfoVO) ?? null;
  }

  function likelyUnixTimestamp(obj) {
    const values = Object.values(obj ?? {})
      .filter(value => typeof value === 'number' && Number.isFinite(value) && value > 1_500_000_000 && value < 2_500_000_000);
    return values.length ? Math.max(...values) : 0;
  }

  function getWarTimestamp(war) {
    const { timestampField } = resolveCowLogFields();
    const direct = timestampField ? Number(war?.[timestampField]) : 0;
    return Number.isFinite(direct) && direct > 0 ? direct : likelyUnixTimestamp(war);
  }

  function getHistoryArgs(war) {
    const { seasonField, warField } = resolveCowLogFields();
    return [war?.[seasonField], war?.[warField]];
  }

  function getHistoryBattles(history) {
    return Array.isArray(history?.attack) ? history.attack : [];
  }

  function getBattleDefender(vo) {
    return callSemantic(vo, 'get_defender', { optional: true }) ?? null;
  }

  function getBattleTimestamp(rawBattle) {
    const direct = Number(rawBattle?.time ?? rawBattle?.startTime ?? rawBattle?.ctime ?? 0);
    if (Number.isFinite(direct) && direct > 0) return direct;
    const nested = Object.values(rawBattle ?? {}).find(value => value && typeof value === 'object' && Number.isFinite(Number(value.time)));
    return Number(nested?.time ?? 0) || 0;
  }

  function formatBattleDate(value, timestamp = 0) {
    const pad2 = number => String(number).padStart(2, '0');
    const text = String(value ?? '').trim();

    // Hero Wars currently returns CoW dates as dd-mm-yyyy hh:mm. Keep the
    // game's displayed clock time and normalize only the field order.
    let match = text.match(/^(\d{1,2})[-/.](\d{1,2})[-/.](\d{4})(?:\s+(\d{1,2}):(\d{2}))?/);
    if (match) {
      const [, day, month, year, hour = '00', minute = '00'] = match;
      return `${year}-${pad2(month)}-${pad2(day)} ${pad2(hour)}:${pad2(minute)}`;
    }

    // Already year-first: normalize separators / zero padding.
    match = text.match(/^(\d{4})[-/.](\d{1,2})[-/.](\d{1,2})(?:\s+(\d{1,2}):(\d{2}))?/);
    if (match) {
      const [, year, month, day, hour = '00', minute = '00'] = match;
      return `${year}-${pad2(month)}-${pad2(day)} ${pad2(hour)}:${pad2(minute)}`;
    }

    // Fallback only when no usable game-formatted string exists.
    const ts = Number(timestamp);
    if (Number.isFinite(ts) && ts > 0) {
      const date = new Date(ts * 1000);
      if (!Number.isNaN(date.getTime())) {
        return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())} ${pad2(date.getHours())}:${pad2(date.getMinutes())}`;
      }
    }

    return text;
  }

  function normalizeReplayUnits(replay) {
    const team = replay?.defenders?.['0'] ?? replay?.defenders?.[0] ?? null;
    if (!team) return [];
    const values = Array.isArray(team) ? team : Object.values(team);
    const out = [];
    for (const value of values) {
      if (Array.isArray(value)) out.push(...value);
      else if (value && typeof value === 'object') out.push(value);
    }
    return out;
  }

  function replayToDefenderData(replay) {
    const units = normalizeReplayUnits(replay);
    const heroes = units
      .filter(unit => unit?.type === 'hero')
      .map(unit => ({
        heroId: Number(unit.id),
        patronPetId: Number(unit.favorPetId) > 0 ? Number(unit.favorPetId) : null,
      }))
      .filter(row => Number.isFinite(row.heroId));
    const pet = units.find(unit => unit?.type === 'pet');
    const rawBanner = replay?.effects?.defendersBanner ?? null;
    let warFlag = null;
    if (rawBanner && rawBanner.id != null) {
      const slots = rawBanner.slots ?? [];
      const entries = Array.isArray(slots) ? slots.entries() : Object.entries(slots);
      const patterns = [];
      for (const [slot, patternId] of entries) {
        if (patternId == null) continue;
        patterns.push({ slot: Number(slot), patternId: Number(patternId) });
      }
      warFlag = { bannerId: Number(rawBanner.id), patterns };
    }
    return {
      heroes,
      mainPetId: pet?.id == null ? null : Number(pet.id),
      warFlag,
    };
  }

  function buildCowBattleRecord(rawBattle, replayOverride = null) {
    const BattleVO = findClass(CLASS.cowLogBattleVO);
    const vo = new BattleVO(rawBattle);
    const defender = getBattleDefender(vo);
    const replay = replayOverride ?? callSemantic(vo, 'get_replayRawData', { optional: true }) ?? rawBattle?.replay ?? rawBattle?.ov ?? null;
    const parsed = replayToDefenderData(replay);
    const clan = getClanInfo(defender);
    return {
      guild: getClanTitle(clan),
      guildId: getClanId(clan),
      playerName: getUserName(defender),
      playerId: getUserId(defender),
      building: String(callSemantic(vo, 'get_position', { optional: true }) ?? ''),
      position: String(callSemantic(vo, 'get_positionIndex', { optional: true }) ?? ''),
      timestamp: getBattleTimestamp(rawBattle),
      dateString: formatBattleDate(
        callSemantic(vo, 'get_dateString', { optional: true }) ?? '',
        getBattleTimestamp(rawBattle)
      ),
      replayId: String(callSemantic(vo, 'get_replayId', { optional: true }) ?? ''),
      ...parsed,
    };
  }

  async function loadCowPatronReference(target) {
    if (!target?.guildId || !target?.playerId || !target?.heroKey) fail('PATRON_TARGET_INCOMPLETE');
    const commandList = getCowCommandList();
    const methods = getCowCommandMethods(commandList);
    const rawWars = await commandList[methods.available]();
    const wars = (Array.isArray(rawWars) ? rawWars : Object.values(rawWars ?? {}))
      .slice()
      .sort((a, b) => getWarTimestamp(b) - getWarTimestamp(a));
    const sameGuildWars = wars
      .filter(war => getClanId(getWarEnemyClan(war)) === String(target.guildId))
      .sort((a, b) => getWarTimestamp(b) - getWarTimestamp(a));

    // Metadata only. Replays are fetched individually with battleGetReplay after
    // Guild / Player filters have narrowed the candidates. This mirrors the game's
    // per-battle "i" path and avoids crossClanWar_getWarHistory(..., true).
    const historyCache = new Map();
    const getHistory = async war => {
      const [season, warNo] = getHistoryArgs(war);
      const key = `${String(season)}:${String(warNo)}:0`;
      if (!historyCache.has(key)) {
        historyCache.set(key, Promise.resolve(commandList[methods.history](season, warNo, false)));
      }
      return historyCache.get(key);
    };

    const BattleVO = findClass(CLASS.cowLogBattleVO);
    const getPlayerBattles = history => {
      const rows = [];
      for (const rawBattle of getHistoryBattles(history)) {
        try {
          const vo = new BattleVO(rawBattle);
          const defender = getBattleDefender(vo);
          if (getUserId(defender) === String(target.playerId)) rows.push(rawBattle);
        } catch (error) {
          warn('PATRON_HISTORY_BATTLE_SKIPPED', error);
        }
      }
      return rows;
    };

    const loadRecord = async rawBattle => {
      const vo = new BattleVO(rawBattle);
      const replayId = String(callSemantic(vo, 'get_replayId', { optional: true }) ?? '');
      let replay = callSemantic(vo, 'get_replayRawData', { optional: true }) ?? rawBattle?.replay ?? rawBattle?.ov ?? null;
      if (!replay && replayId) replay = await loadBattleReplayForVo(vo);
      return buildCowBattleRecord(rawBattle, replay);
    };

    let reference = null;
    let warsContainingPlayer = 0;
    let exactHeroMatches = 0;

    // Newest matching war wins. Once an exact setup is found in a newer war,
    // older wars cannot contain a more recent battle timestamp.
    for (const war of sameGuildWars) {
      let history;
      try {
        history = await getHistory(war);
      } catch (error) {
        warn('PATRON_HISTORY_WAR_SKIPPED', error);
        continue;
      }

      const playerBattles = getPlayerBattles(history);
      if (!playerBattles.length) continue;
      warsContainingPlayer += 1;

      const matchesInWar = [];
      for (const rawBattle of playerBattles) {
        try {
          const record = await loadRecord(rawBattle);
          if (record.heroes.length < 1 || record.heroes.length > 5) continue;
          if (sortedIdKey(record.heroes.map(row => row.heroId)) !== target.heroKey) continue;
          if (!record.guild) record.guild = target.guild;
          if (!record.guildId) record.guildId = target.guildId;
          matchesInWar.push(record);
        } catch (error) {
          warn('PATRON_REFERENCE_REPLAY_SKIPPED', error);
        }
      }

      if (matchesInWar.length) {
        matchesInWar.sort((a, b) => b.timestamp - a.timestamp);
        reference = matchesInWar[0];
        exactHeroMatches = matchesInWar.length;
        break;
      }
    }

    // Current matchup only. Used Patrons intentionally excludes battles using the
    // currently selected Hero setup; it shows Patron pets used with other setups.
    const used = { petIds: [], lastSeen: '', lastSeenTimestamp: 0 };
    const currentWar = sameGuildWars[0] ?? null;
    if (currentWar) {
      try {
        const history = await getHistory(currentWar);
        const petIds = new Set();
        for (const rawBattle of getPlayerBattles(history)) {
          try {
            const record = await loadRecord(rawBattle);
            if (record.heroes.length < 1 || record.heroes.length > 5) continue;
            if (sortedIdKey(record.heroes.map(row => row.heroId)) === target.heroKey) continue;
            for (const hero of record.heroes) {
              if (Number(hero.patronPetId) > 0) petIds.add(Number(hero.patronPetId));
            }
            if (record.timestamp >= used.lastSeenTimestamp) {
              used.lastSeenTimestamp = record.timestamp;
              used.lastSeen = record.dateString;
            }
          } catch (error) {
            warn('PATRON_USED_REPLAY_SKIPPED', error);
          }
        }
        used.petIds = [...petIds].filter(id => Number(id) > 0).sort((a, b) => a - b);
      } catch (error) {
        // Used Patrons is supplemental. Do not fail Past Setup or Combat Training.
        warn('PATRON_USED_HISTORY_SKIPPED', error);
      }
    }

    return {
      reference,
      used,
      stats: {
        warsAvailable: wars.length,
        warsSameGuild: sameGuildWars.length,
        warsContainingPlayer,
        exactHeroMatches,
      },
    };
  }

  function getDeclaredFunctionName(fn) {
    if (typeof fn !== 'function') return '';
    const source = String(fn);
    return source.match(/^function\s+([A-Za-z_$][\w$]*)\s*\(/)?.[1] ?? fn.name ?? '';
  }

  function getChainedMethodAfterCall(source, methodName) {
    const needle = `.${methodName}(`;
    const start = source.indexOf(needle);
    if (start < 0) return null;

    let index = start + needle.length;
    let depth = 1;
    let quote = '';
    let escaped = false;
    for (; index < source.length; index += 1) {
      const ch = source[index];
      if (quote) {
        if (escaped) escaped = false;
        else if (ch === '\\') escaped = true;
        else if (ch === quote) quote = '';
        continue;
      }
      if (ch === '"' || ch === "'" || ch === '`') {
        quote = ch;
        continue;
      }
      if (ch === '(') depth += 1;
      else if (ch === ')') {
        depth -= 1;
        if (depth === 0) break;
      }
    }
    if (depth !== 0) return null;

    index += 1;
    while (/\s/.test(source[index] ?? '')) index += 1;
    if (source[index] !== '.') return null;
    const match = source.slice(index + 1).match(/^([A-Za-z_$][\w$]*)\s*\(/);
    return match?.[1] ?? null;
  }

  function getGwCommandMethods(commandList) {
    const Available = findClass(CLASS.gwAvailableHistoryCommand);
    const Day = findClass(CLASS.gwDayHistoryCommand);
    const availableCtor = getDeclaredFunctionName(Available);
    const dayCtor = getDeclaredFunctionName(Day);
    if (!availableCtor || !dayCtor) fail('GW_COMMAND_CONSTRUCTOR_NAME_NOT_FOUND');

    const available = findMethodBySource(
      commandList,
      source => source.includes(`new ${availableCtor}`),
      'GW_AVAILABLE_HISTORY_METHOD_NOT_FOUND'
    );
    const day = findMethodBySource(
      commandList,
      source => source.includes(`new ${dayCtor}`),
      'GW_DAY_HISTORY_METHOD_NOT_FOUND'
    );

    const Mediator = findClass(CLASS.gwLogPopupMediator);
    const completionNames = [...new Set(
      Object.getOwnPropertyNames(Mediator.prototype)
        .map(name => typeof Mediator.prototype[name] === 'function' ? String(Mediator.prototype[name]) : '')
        .map(source => getChainedMethodAfterCall(source, day))
        .filter(Boolean)
    )];
    if (completionNames.length !== 1) {
      fail(completionNames.length ? 'GW_COMMAND_COMPLETION_METHOD_AMBIGUOUS' : 'GW_COMMAND_COMPLETION_METHOD_NOT_FOUND');
    }
    const completion = completionNames[0];

    // Validate the dynamically discovered completion method against the shared RPC base.
    const RpcBase = findClass(CLASS.rpcCommandBase);
    if (typeof RpcBase.prototype?.[completion] !== 'function' || RpcBase.prototype[completion].length !== 1) {
      fail('GW_COMMAND_COMPLETION_METHOD_INVALID', completion);
    }

    return { available, day, completion };
  }

  function awaitGwCommand(startCommand, completionMethod, code) {
    return new Promise((resolve, reject) => {
      let settled = false;
      let timer = null;
      const finish = (callback, value) => {
        if (settled) return;
        settled = true;
        if (timer) clearTimeout(timer);
        callback(value);
      };

      try {
        const command = startCommand();
        if (!command) fail(`${code}_NOT_CREATED`);
        const subscribe = command[completionMethod];
        if (typeof subscribe !== 'function') fail(`${code}_COMPLETION_METHOD_MISSING`, completionMethod);

        timer = setTimeout(() => {
          finish(reject, new HWCTError(`${code}_TIMEOUT`));
        }, 8000);

        subscribe.call(command, completed => {
          finish(resolve, completed ?? command);
        });
      } catch (error) {
        finish(reject, error);
      }
    });
  }

  function getDirectClassValue(obj, fullClass, { optional = false, code = 'DIRECT_CLASS_VALUE' } = {}) {
    const matches = uniqueRefs(Object.values(obj ?? {}).filter(value => className(value) === fullClass));
    if (matches.length === 1) return matches[0];
    if (optional && matches.length === 0) return null;
    if (matches.length === 0) fail(`${code}_NOT_FOUND`, fullClass);
    fail(`${code}_AMBIGUOUS`, `${fullClass} (${matches.length})`);
  }

  function getGwLogDay(log) {
    return getDirectClassValue(log, CLASS.gwDayVO, { optional: true, code: 'GW_LOG_DAY' });
  }

  function getGwLogEnemyClan(log) {
    return getDirectClassValue(log, CLASS.clanBasicInfoVO, { optional: true, code: 'GW_LOG_ENEMY_CLAN' });
  }

  function getGwDaySortValue(day) {
    if (!day) return 0;
    const seasonText = String(callSemantic(day, 'get_season', { optional: true }) ?? '').replace(/\D/g, '');
    const season = Number(seasonText);
    const dayNo = Number(callSemantic(day, 'get_day', { optional: true }));
    return (Number.isFinite(season) ? season : 0) * 10 + (Number.isFinite(dayNo) ? dayNo : 0);
  }

  function getGwDayKey(day) {
    return `${String(callSemantic(day, 'get_season', { optional: true }) ?? '')}:${String(callSemantic(day, 'get_day', { optional: true }) ?? '')}`;
  }

  function getGwAttackBattleEntries(warEntry) {
    if (!warEntry || className(warEntry) !== CLASS.gwLogWarEntry) return [];
    const attack = callSemantic(warEntry, 'get_attack', { optional: true });
    const entries = Array.isArray(attack) ? attack : [];
    return entries.filter(entry => {
      if (!entry || className(entry) !== CLASS.gwLogBattleEntry) return false;
      const attacker = callSemantic(entry, 'get_attacker', { optional: true });
      const defender = callSemantic(entry, 'get_defender', { optional: true });
      const replay = callSemantic(entry, 'get_replay', { optional: true });
      if (!attacker || !defender || !replay) return false;
      if (String(replay.type ?? '') !== 'clan_pvp') return false;
      const isHeroTeam = callSemantic(defender, 'get_isHeroTeam', { optional: true });
      return isHeroTeam !== false;
    });
  }

  function getGwDefenderUserId(entry) {
    const defender = callSemantic(entry, 'get_defender', { optional: true });
    if (!defender) return '';
    const user = callSemantic(defender, 'get_user', { optional: true }) ?? null;
    const fromUser = getUserId(user);
    if (fromUser) return fromUser;
    const value = callSemantic(defender, 'get_userId', { optional: true });
    return value == null ? '' : String(value);
  }

  function buildGwBattleRecord(entry) {
    const defender = callSemantic(entry, 'get_defender', { optional: true });
    const user = defender ? (callSemantic(defender, 'get_user', { optional: true }) ?? null) : null;
    const replay = callSemantic(entry, 'get_replay', { optional: true });
    if (!replay) fail('GW_REPLAY_MISSING');
    const parsed = replayToDefenderData(replay);
    const clan = getClanInfo(user);
    const rawSlotId = Number(callSemantic(entry, 'get_slotId', { optional: true }));
    const slotId = Number.isFinite(rawSlotId) ? rawSlotId : null;
    const rawTimestamp = Number(callSemantic(entry, 'get_timestamp', { optional: true }));
    const replayTimestamp = Number(replay.startTime ?? 0);
    const timestamp = Number.isFinite(rawTimestamp) && rawTimestamp > 0
      ? rawTimestamp
      : (Number.isFinite(replayTimestamp) && replayTimestamp > 0 ? replayTimestamp : 0);

    return {
      guild: getClanTitle(clan),
      guildId: getClanId(clan),
      playerName: getUserName(user),
      playerId: getGwDefenderUserId(entry),
      // Historical slotId is globally numbered. Safe building/position remapping is deferred.
      building: 'GW slot',
      position: slotId == null ? '?' : String(slotId),
      slotId,
      timestamp,
      dateString: formatBattleDate('', timestamp),
      replayId: String(replay.id ?? ''),
      ...parsed,
    };
  }

  async function loadGwPatronReference(target) {
    if (!target?.guildId || !target?.playerId || !target?.heroKey) fail('PATRON_TARGET_INCOMPLETE');
    const commandList = getGwCommandList();
    const methods = getGwCommandMethods(commandList);

    const availableCommand = await awaitGwCommand(
      () => commandList[methods.available](),
      methods.completion,
      'GW_AVAILABLE_HISTORY'
    );
    const rawLogs = callSemantic(availableCommand, 'get_logs', { optional: true });
    const logs = (Array.isArray(rawLogs) ? rawLogs : [])
      .filter(log => className(log) === CLASS.gwLogEntry && getGwLogDay(log))
      .slice()
      .sort((a, b) => getGwDaySortValue(getGwLogDay(b)) - getGwDaySortValue(getGwLogDay(a)));
    const sameGuildLogs = logs.filter(log => getClanId(getGwLogEnemyClan(log)) === String(target.guildId));

    const dayCache = new Map();
    const getDayHistory = async log => {
      const day = getGwLogDay(log);
      if (!day) fail('GW_LOG_DAY_NOT_FOUND');
      const key = getGwDayKey(day);
      if (!dayCache.has(key)) {
        dayCache.set(key, (async () => {
          // The game command sends season/day to the server. The remaining constructor
          // arguments only decorate the local top-level log VO, so null is sufficient here.
          const command = await awaitGwCommand(
            () => commandList[methods.day](day, 0, null, null),
            methods.completion,
            'GW_DAY_HISTORY'
          );
          const warEntry = callSemantic(command, 'get_log', { optional: true });
          if (!warEntry) fail('GW_DAY_HISTORY_LOG_MISSING', key);
          return warEntry;
        })());
      }
      return dayCache.get(key);
    };

    const getPlayerBattles = warEntry => getGwAttackBattleEntries(warEntry)
      .filter(entry => getGwDefenderUserId(entry) === String(target.playerId));

    let reference = null;
    let daysContainingPlayer = 0;
    let exactHeroMatches = 0;

    // Search newest same-guild GW days first. The first day with an exact Hero
    // match is necessarily newer than all remaining candidate days.
    for (const logEntry of sameGuildLogs) {
      let warEntry;
      try {
        warEntry = await getDayHistory(logEntry);
      } catch (error) {
        warn('GW_PATRON_DAY_SKIPPED', error);
        continue;
      }

      const playerBattles = getPlayerBattles(warEntry);
      if (!playerBattles.length) continue;
      daysContainingPlayer += 1;

      const matchesInDay = [];
      for (const battle of playerBattles) {
        try {
          const record = buildGwBattleRecord(battle);
          if (record.heroes.length < 1 || record.heroes.length > 5) continue;
          if (sortedIdKey(record.heroes.map(row => row.heroId)) !== target.heroKey) continue;
          if (!record.guild) record.guild = target.guild;
          if (!record.guildId) record.guildId = target.guildId;
          if (!record.playerName) record.playerName = target.playerName;
          matchesInDay.push(record);
        } catch (error) {
          warn('GW_PATRON_REFERENCE_BATTLE_SKIPPED', error);
        }
      }

      if (matchesInDay.length) {
        matchesInDay.sort((a, b) => b.timestamp - a.timestamp);
        reference = matchesInDay[0];
        exactHeroMatches = matchesInDay.length;
        break;
      }
    }

    if (reference?.slotId != null) {
      const location = resolveGwHistoricalLocation(reference.slotId, target);
      if (location) {
        reference.building = location.building;
        reference.position = location.position;
      }
    }

    // GW has one defense per player, so the cross-lineup "Used Patrons"
    // reference used by CoW does not apply here.
    const used = { petIds: [], lastSeen: '', lastSeenTimestamp: 0 };

    return {
      reference,
      used,
      stats: {
        daysAvailable: logs.length,
        daysSameGuild: sameGuildLogs.length,
        daysContainingPlayer,
        exactHeroMatches,
      },
    };
  }


  function getHeroDescriptionStorage() {
    if (unitDescriptionStorageCache) return unitDescriptionStorageCache;
    const DataStorage = findClass(CLASS.dataStorage);
    const storages = uniqueRefs(
      Object.values(DataStorage).filter(value => value?.__class__?.j?.endsWith('.HeroDescriptionStorage'))
    );
    if (storages.length !== 1) fail(storages.length ? 'HERO_DESCRIPTION_STORAGE_AMBIGUOUS' : 'HERO_DESCRIPTION_STORAGE_NOT_FOUND');
    unitDescriptionStorageCache = storages[0];
    return unitDescriptionStorageCache;
  }

  function getUnitDescriptionLookupMethod(storage) {
    if (unitDescriptionLookupMethodCache) return unitDescriptionLookupMethodCache;

    // HeroDescriptionStorage has one generic, read-only ID lookup used for heroes,
    // pets and titans. Resolve that exact getter shape instead of trying to infer a
    // whole-list method. The previous list resolver became ambiguous in the live
    // build because two harmless list methods shared the broad Object.keys/push
    // signature.
    unitDescriptionLookupMethodCache = findMethodBySource(
      storage,
      (source, fn) => {
        if (fn.length !== 1) return false;
        const compact = source.replace(/\s+/g, '');
        const match = /^function\(([$\w]+)\)\{returnthis\.[$\w]+\.[$\w]+\[null==\1\?"null":""\+\1\]\}$/.exec(compact);
        return Boolean(match);
      },
      'HERO_DESCRIPTION_ID_LOOKUP_METHOD_NOT_FOUND'
    );
    return unitDescriptionLookupMethodCache;
  }

  function getUnitDescription(id) {
    const numericId = Number(id);
    if (!Number.isFinite(numericId)) return null;
    const storage = getHeroDescriptionStorage();
    const lookupMethod = getUnitDescriptionLookupMethod(storage);
    const description = storage[lookupMethod](numericId) ?? null;
    if (!description) return null;

    // Fail closed if a future client changes the storage getter semantics.
    const resolvedId = Number(callSemantic(description, 'get_id', { optional: true }));
    if (!Number.isFinite(resolvedId) || resolvedId !== numericId) {
      fail('HERO_DESCRIPTION_ID_LOOKUP_MISMATCH', `${numericId}->${resolvedId}`);
    }
    return description;
  }

  function getBannerDescriptionStorage() {
    if (bannerDescriptionStorageCache) return bannerDescriptionStorageCache;
    const DataStorage = findClass(CLASS.dataStorage);
    const storages = uniqueRefs(
      Object.values(DataStorage).filter(value => className(value) === CLASS.bannerDescriptionStorage)
    );
    if (storages.length !== 1) fail(storages.length ? 'BANNER_DESCRIPTION_STORAGE_AMBIGUOUS' : 'BANNER_DESCRIPTION_STORAGE_NOT_FOUND');
    bannerDescriptionStorageCache = storages[0];
    return bannerDescriptionStorageCache;
  }

  function getBannerDescriptionLookupMethod(storage, probeId) {
    if (bannerDescriptionLookupMethodCache) return bannerDescriptionLookupMethodCache;

    const numericId = Number(probeId);
    if (!Number.isFinite(numericId)) fail('BANNER_DESCRIPTION_LOOKUP_PROBE_ID_INVALID', String(probeId));

    // Live v0.3.14 investigation proved BannerDescriptionStorage inherits a pure
    // one-argument map getter whose current minified shape is:
    //   function(a){return this.<map>.<field>[a]}
    // Older storages may use the equivalent null/string-normalized key shape.
    // Consider only those read-only getter forms, then validate the returned
    // object is exactly BannerDescription with the requested ID before caching.
    // This avoids binding a minified method name and avoids executing unrelated
    // one-argument methods.
    const candidates = prototypeMethodsDeep(storage).filter(({ source, fn }) => {
      if (fn.length !== 1) return false;
      const compact = source.replace(/\s+/g, '');
      const direct = /^function\(([$\w]+)\)\{returnthis\.[$\w]+\.[$\w]+\[\1\]\}$/.test(compact);
      const normalized = /^function\(([$\w]+)\)\{returnthis\.[$\w]+\.[$\w]+\[null==\1\?"null":""\+\1\]\}$/.test(compact);
      return direct || normalized;
    });

    const validated = candidates.filter(({ name }) => {
      try {
        const description = storage[name](numericId) ?? null;
        if (!description || className(description) !== CLASS.bannerDescription) return false;
        const resolvedId = Number(callSemantic(description, 'get_id', { optional: true }));
        return Number.isFinite(resolvedId) && resolvedId === numericId;
      } catch {
        return false;
      }
    });

    if (validated.length === 1) {
      bannerDescriptionLookupMethodCache = validated[0].name;
      return bannerDescriptionLookupMethodCache;
    }
    if (validated.length === 0) {
      fail('BANNER_DESCRIPTION_ID_LOOKUP_METHOD_NOT_FOUND', `candidates=${candidates.length}`);
    }
    fail('BANNER_DESCRIPTION_ID_LOOKUP_METHOD_AMBIGUOUS', String(validated.length));
  }

  function getBannerDescription(id) {
    const numericId = Number(id);
    if (!Number.isFinite(numericId)) return null;
    const storage = getBannerDescriptionStorage();
    const lookupMethod = getBannerDescriptionLookupMethod(storage, numericId);
    const description = storage[lookupMethod](numericId) ?? null;
    if (!description) return null;
    const resolvedId = Number(callSemantic(description, 'get_id', { optional: true }));
    if (!Number.isFinite(resolvedId) || resolvedId !== numericId) {
      fail('BANNER_DESCRIPTION_ID_LOOKUP_MISMATCH', `${numericId}->${resolvedId}`);
    }
    return description;
  }

  function getInventoryAssetStorage() {
    if (inventoryAssetStorageCache) return inventoryAssetStorageCache;
    const AssetStorage = findClass(CLASS.assetStorage);
    const matches = uniqueRefs(
      Object.values(AssetStorage).filter(value => className(value) === CLASS.inventoryAssetStorage)
    );
    if (matches.length !== 1) {
      fail(matches.length ? 'INVENTORY_ASSET_STORAGE_AMBIGUOUS' : 'INVENTORY_ASSET_STORAGE_NOT_FOUND');
    }
    inventoryAssetStorageCache = matches[0];
    return inventoryAssetStorageCache;
  }

  function getBannerBodyTextureMethod(storage, probeDescription) {
    if (bannerBodyTextureMethodCache) return bannerBodyTextureMethodCache;
    if (!probeDescription) fail('BANNER_BODY_TEXTURE_PROBE_MISSING');

    // Current client route observed live: function(a){return <atlas>.sc(a.<textureId>)}.
    // Do not require the historical 84x84 region: Starling may trim transparent
    // margins and expose the logical size through frame instead. The wrapper shape,
    // BannerDescription probe, SubTexture class, and positive region are enough to
    // identify the native flag body without binding a minified method name.
    const candidates = prototypeMethodsDeep(storage).filter(({ source, fn }) => {
      if (fn.length !== 1) return false;
      const compact = source.replace(/\s+/g, '');
      return /^function\(([$\w]+)\)\{return[$\w.]+\.[$\w]+\(\1\.[$\w]+\)\}$/.test(compact);
    });

    const validated = candidates.filter(({ name }) => {
      try {
        const texture = storage[name](probeDescription) ?? null;
        if (!texture || className(texture) !== CLASS.subTexture) return false;
        const region = callSemantic(texture, 'get_region', { optional: true });
        return Number(region?.width) > 0 && Number(region?.height) > 0;
      } catch {
        return false;
      }
    });

    if (validated.length === 1) {
      bannerBodyTextureMethodCache = validated[0].name;
      return bannerBodyTextureMethodCache;
    }
    if (validated.length === 0) {
      fail('BANNER_BODY_TEXTURE_METHOD_NOT_FOUND', `candidates=${candidates.length}`);
    }
    fail('BANNER_BODY_TEXTURE_METHOD_AMBIGUOUS', String(validated.length));
  }

  function getNativeWarFlagTexture(id) {
    const numericId = Number(id);
    if (!Number.isFinite(numericId) || numericId <= 0) return null;
    const description = getBannerDescription(numericId);
    if (!description) return null;
    const storage = getInventoryAssetStorage();
    const method = getBannerBodyTextureMethod(storage, description);
    const texture = storage[method](description) ?? null;
    if (!texture || className(texture) !== CLASS.subTexture) return null;
    const region = callSemantic(texture, 'get_region', { optional: true });
    if (!(Number(region?.width) > 0 && Number(region?.height) > 0)) return null;
    return texture;
  }

  async function getWarFlagDataUrl(id) {
    const numericId = Number(id);
    if (!Number.isFinite(numericId) || numericId <= 0) return null;
    if (warFlagDataUrlCache.has(numericId)) return warFlagDataUrlCache.get(numericId);
    if (warFlagDataUrlPromiseCache.has(numericId)) return warFlagDataUrlPromiseCache.get(numericId);

    const promise = Promise.resolve().then(() => {
      try {
        const texture = getNativeWarFlagTexture(numericId);
        if (!texture) return null;
        const dataUrl = cropSubTextureToDataUrl(texture);
        if (!dataUrl) return null;
        warFlagDataUrlCache.set(numericId, dataUrl);
        return dataUrl;
      } catch (error) {
        warn(`WAR_FLAG_ICON_FAILED_${numericId}`, error);
        return null;
      }
    });

    warFlagDataUrlPromiseCache.set(numericId, promise);
    try {
      return await promise;
    } finally {
      warFlagDataUrlPromiseCache.delete(numericId);
    }
  }


  function getPatternName(pattern) {
    if (!pattern) return '';
    try {
      const semanticName = callSemantic(pattern, 'get_name', { optional: true });
      if (semanticName != null && String(semanticName).trim()) return String(semanticName).trim();
    } catch {}
    for (const key of ['ri', 'si', 'name']) {
      const value = pattern?.[key];
      if (value != null && String(value).trim()) return String(value).trim();
    }
    return '';
  }

  function getPatternBuffValue(pattern) {
    if (!pattern) return null;
    try {
      const buff = callSemantic(pattern, 'get_firstBuff', { optional: true });
      const value = buff ? Number(callSemantic(buff, 'get_value', { optional: true })) : NaN;
      return Number.isFinite(value) ? value : null;
    } catch {
      return null;
    }
  }

  function getAbsolutePatternFor(currentPattern, absolutePatterns = null) {
    if (!currentPattern) return null;
    const pool = absolutePatterns ?? getAbsolutePatterns();
    const key = getPatternTypeKey(currentPattern);
    const matches = pool.filter(pattern => getPatternTypeKey(pattern) === key);
    return matches.length === 1 ? matches[0] : null;
  }

  function buildCurrentDefenseReference(slot) {
    const bannerVO = callSemantic(slot, 'get_banner', { optional: true }) ?? null;
    if (!bannerVO) return { bannerId: null, flagName: '', patterns: [] };

    const entry = getCurrentBannerEntry(bannerVO);
    const desc = entry ? callSemantic(entry, 'get_desc', { optional: true }) : null;
    const bannerIdRaw = desc ? Number(callSemantic(desc, 'get_id', { optional: true })) : NaN;
    const bannerId = Number.isFinite(bannerIdRaw) ? bannerIdRaw : null;
    const flagName = String(
      (desc ? callSemantic(desc, 'get_name', { optional: true }) : null) ?? desc?.ri ?? ''
    );

    const map = getIntMapFromBannerEntry(entry);
    const rows = getPatternEntries(map)
      .filter(([patternSlot, pattern]) => Number.isFinite(patternSlot) && pattern?.__class__?.j === CLASS.bannerStoneDescription)
      .sort((a, b) => a[0] - b[0]);

    // Derived Pattern metadata must never be allowed to erase the whole Current
    // Defense. The 2026-08-15 client changed minified Pattern fields; rc19 let a
    // color-tier/type-key failure throw out bannerId + every Pattern, which is why
    // the UI showed four dashes even though the live slot still contained them.
    const patterns = rows.map(([patternSlot, pattern]) => {
      let colorTier = null;
      try { colorTier = getPatternColorTier(pattern); }
      catch (error) { warn('CURRENT_PATTERN_COLOR_TIER_SKIPPED', { slot: Number(patternSlot), error }); }

      let resourceKey = '';
      try { resourceKey = getPatternTypeKey(pattern); }
      catch {}

      let ultimateLevel = Number.isFinite(Number(pattern?.level)) ? Number(pattern.level) : null;
      if (ultimateLevel == null) {
        try {
          const semanticLevel = callSemantic(pattern, 'get_level', { optional: true });
          if (Number.isFinite(Number(semanticLevel))) ultimateLevel = Number(semanticLevel);
        } catch {}
      }

      return {
        slot: Number(patternSlot),
        pattern,
        name: getPatternName(pattern),
        resourceKey,
        currentValue: getPatternBuffValue(pattern),
        colorTier,
        ultimateLevel,
      };
    });

    return { bannerId, flagName, patterns };
  }

  function getPatternAssetMapperMethod() {
    if (patternAssetMapperMethodCache) return patternAssetMapperMethodCache;
    const AssetUtil = findClass(CLASS.assetStorageUtil);
    const Provider = findClass(CLASS.iconContentProvider);
    const setItem = findSetter(Provider, 'set_item');
    const source = String(Provider.prototype?.[setItem]);
    const arg = source.match(/^function\(([$\w]+)\)/)?.[1];
    if (!arg) fail('PATTERN_ASSET_MAPPER_ARG_NOT_FOUND');
    const escaped = arg.replace(/[$]/g, '\\$&');
    const names = [...source.matchAll(new RegExp(`\\.([A-Za-z_$][\\w$]*)\\(${escaped}\\)`, 'g'))]
      .map(match => match[1]);
    const candidates = [...new Set(names)].filter(name => typeof AssetUtil[name] === 'function' && AssetUtil[name].length === 1);
    if (candidates.length !== 1) fail('PATTERN_ASSET_MAPPER_NOT_FOUND', String(candidates.length));
    patternAssetMapperMethodCache = candidates[0];
    return patternAssetMapperMethodCache;
  }

  function getPatternIconAsset(pattern) {
    const AssetUtil = findClass(CLASS.assetStorageUtil);
    const method = getPatternAssetMapperMethod();
    const asset = AssetUtil[method](pattern) ?? null;
    return className(asset) === CLASS.atlasTextureIconAsset ? asset : null;
  }

  function findPureZeroArgResult(obj, validator) {
    if (!obj?.__class__) return null;
    const candidates = [];
    for (const { name, fn, source } of prototypeMethodsDeep(obj)) {
      if (fn.length !== 0) continue;
      const compact = source.replace(/\s+/g, '');
      if (!/^function\(\)\{return/.test(compact)) continue;
      try {
        const value = obj[name]();
        if (validator(value)) candidates.push(value);
      } catch {}
    }
    return uniqueRefs(candidates).length === 1 ? uniqueRefs(candidates)[0] : null;
  }

  function getPatternAtlasBundle(asset) {
    return findPureZeroArgResult(asset, value => className(value) === CLASS.iconAtlasAsset);
  }

  function getPatternSubTexture(asset) {
    const matches = [];
    for (const { name, fn, source } of prototypeMethodsDeep(asset)) {
      if (fn.length !== 0) continue;
      const compact = source.replace(/\s+/g, '');
      // Current client route is return <atlas>.sc(this.<atlasId>,this.<textureIdent>).
      // The old 72x72 guard is no longer safe because Starling can trim transparent
      // margins and place the logical size in frame. The two-this-argument wrapper
      // already excludes the preview/global-texture route; validate only SubTexture
      // plus a positive region here.
      if (!/^function\(\)\{return[$\w.]+\.[$\w]+\(this\.[$\w]+,this\.[$\w]+\)\}$/.test(compact)) continue;
      try {
        const value = asset[name]();
        if (className(value) !== CLASS.subTexture) continue;
        const region = callSemantic(value, 'get_region', { optional: true });
        if (Number(region?.width) > 0 && Number(region?.height) > 0) matches.push(value);
      } catch {}
    }
    const unique = uniqueRefs(matches);
    return unique.length === 1 ? unique[0] : null;
  }

  function findStringsLimited(root, predicate, { maxDepth = 4, maxNodes = 100 } = {}) {
    const out = [];
    const seen = new Set();
    const queue = [{ value: root, depth: 0 }];
    let nodes = 0;
    while (queue.length && nodes < maxNodes) {
      const { value, depth } = queue.shift();
      if (typeof value === 'string') {
        if (predicate(value)) out.push(value);
        continue;
      }
      if (!value || typeof value !== 'object' || seen.has(value) || depth >= maxDepth) continue;
      seen.add(value);
      nodes += 1;
      if (ArrayBuffer.isView(value) || value instanceof ArrayBuffer || value instanceof Node) continue;
      let children = [];
      try { children = Object.values(value); } catch { continue; }
      for (const child of children) {
        if (typeof child === 'string') {
          if (predicate(child)) out.push(child);
        } else if (child && typeof child === 'object') {
          queue.push({ value: child, depth: depth + 1 });
        }
      }
    }
    return [...new Set(out)];
  }

  function getGenericAtlasInfo(asset) {
    const bundle = getPatternAtlasBundle(asset);
    if (!bundle) return null;
    if (unitAtlasInfoCache.has(bundle)) return unitAtlasInfoCache.get(bundle);

    // Prefer a real ImageFile when the current IconAtlasAsset exposes one.
    const directImageFile = findObjectByClassLimited(
      bundle,
      'engine.core.assets.file.ImageFile',
      { maxDepth: 7, maxNodes: 700 }
    );
    const directUrl = getImageFileUrl(directImageFile);
    if (directUrl) {
      const info = { url: directUrl };
      unitAtlasInfoCache.set(bundle, info);
      return info;
    }

    // Current Hero atlases are larger object graphs than Pattern atlases. The rc17
    // fallback stopped too early for some Hero IconAtlasAsset bundles. Scan the
    // bundle itself with a bounded but wider read-only traversal.
    const strings = findStringsLimited(bundle, () => true, { maxDepth: 7, maxNodes: 700 });
    const bases = strings.filter(value => /^https:\/\/[^\s]+\/assets\/$/i.test(value));
    const query = strings.find(value => /^\?js=\d+$/i.test(value)) ?? '';
    const paths = [...new Set(strings.filter(value =>
      !/^https?:\/\//i.test(value) &&
      /(?:^|\/)[^/]+\.png$/i.test(value)
    ))];

    // ResourceTiming often has the exact currently loaded hashed atlas URL even
    // when the IconAtlasAsset graph only exposes a logical filename.
    const resourceUrls = (() => {
      try {
        return performance.getEntriesByType('resource')
          .map(entry => String(entry?.name ?? ''))
          .filter(url => /^https?:\/\//i.test(url) && /\.png(?:\?|$)/i.test(url));
      } catch {
        return [];
      }
    })();

    const candidates = new Set();
    for (const path of paths) {
      for (const base of bases) candidates.add(`${base}${path}${query}`);
      const filename = path.split('/').pop()?.replace(/\.[a-f0-9]{16,}(?=\.png$)/i, '') ?? '';
      if (filename) {
        for (const url of resourceUrls) {
          const clean = url.split('?')[0];
          const resourceName = clean.split('/').pop()?.replace(/\.[a-f0-9]{16,}(?=\.png$)/i, '') ?? '';
          if (resourceName === filename) candidates.add(url);
        }
      }
    }

    // Score with the asset's own string identifiers (atlas id / texture id). This
    // handles bundles containing more than one PNG without binding minified fields.
    const assetTokens = Object.values(asset ?? {})
      .filter(value => typeof value === 'string')
      .map(value => value.trim().toLowerCase())
      .filter(value => value.length >= 3);
    const rows = [...candidates].map(url => {
      const lower = url.toLowerCase();
      let score = 0;
      for (const token of assetTokens) {
        if (lower.includes(token)) score += Math.min(30, 4 + token.length);
      }
      // Prefer hashed production files over logical aliases when otherwise equal.
      if (/\.[a-f0-9]{16,}\.png(?:\?|$)/i.test(url)) score += 2;
      return { url, score };
    }).sort((a, b) => b.score - a.score || a.url.length - b.url.length);

    if (!rows.length) return null;
    if (rows.length > 1 && rows[0].score === rows[1].score && rows[0].score === 0) {
      warn('GENERIC_ATLAS_URL_AMBIGUOUS', { candidateCount: rows.length, assetTokens, candidates: rows.slice(0, 8) });
      return null;
    }
    const info = { url: rows[0].url };
    unitAtlasInfoCache.set(bundle, info);
    return info;
  }

  function getPatternAtlasInfo(asset) {
    const bundle = getPatternAtlasBundle(asset);
    if (!bundle) return null;
    if (patternAtlasInfoCache.has(bundle)) return patternAtlasInfoCache.get(bundle);

    const roots = Object.values(bundle ?? {}).filter(value => value && typeof value === 'object');
    const allStrings = [];
    for (const root of roots) {
      allStrings.push(...findStringsLimited(root, () => true, { maxDepth: 4, maxNodes: 90 }));
    }
    const strings = [...new Set(allStrings)];
    const path = strings.find(value => /(?:^|\/)inventory_icons\/banner_stone_icons\.[a-f0-9]+\.png$/i.test(value))
      ?? strings.find(value => /(?:^|\/)inventory_icons\/banner_stone_icons\.png$/i.test(value));
    const base = strings.find(value => /^https:\/\/[^\s]+\/assets\/$/i.test(value));
    const query = strings.find(value => /^\?js=\d+$/i.test(value)) ?? '';
    if (!path || !base) return null;
    const info = { url: `${base}${path}${query}` };
    patternAtlasInfoCache.set(bundle, info);
    return info;
  }

  async function createPatternIconElement(pattern, displaySize = 50) {
    if (!pattern) return null;
    let prepared = null;
    try {
      const asset = getPatternIconAsset(pattern);
      if (!asset) return null;

      let texture = getPatternSubTexture(asset);
      if (!texture) {
        // Current client can expose the Pattern atlas URL before its SubTexture is
        // ready. Acquire only this icon asset, wait for Je(), then retry sc().
        prepared = await ensureIconAssetReady(asset, `pattern:${String(callSemantic(pattern, 'get_id', { optional: true }) ?? '?')}`);
        if (prepared.ready) texture = getPatternSubTexture(asset);
      }

      const info = getPatternAtlasInfo(asset) ?? getGenericAtlasInfo(asset);
      const region = texture ? callSemantic(texture, 'get_region', { optional: true }) : null;
      if (!info?.url || !region || Number(region.width) <= 0 || Number(region.height) <= 0) {
        warn('PATTERN_ICON_PIPELINE_INCOMPLETE', {
          hasTexture: Boolean(texture),
          hasUrl: Boolean(info?.url),
          region: region ? { x: region.x, y: region.y, width: region.width, height: region.height } : null,
        });
        return null;
      }

      const size = Math.max(24, Number(displaySize) || 50);
      const frame = callSemantic(texture, 'get_frame', { optional: true });
      const logicalWidth = Number(frame?.width ?? region.width) || 1;
      const logicalHeight = Number(frame?.height ?? region.height) || 1;
      const scale = Math.min(size / logicalWidth, size / logicalHeight);
      const frameX = Number(frame?.x ?? 0);
      const frameY = Number(frame?.y ?? 0);
      const viewport = document.createElement('div');
      viewport.className = 'pattern-viewport';
      viewport.style.width = `${size}px`;
      viewport.style.height = `${size}px`;

      const img = document.createElement('img');
      img.className = 'pattern-atlas-img';
      img.alt = '';
      const applyCrop = () => {
        if (!img.naturalWidth || !img.naturalHeight) return;
        img.style.width = `${img.naturalWidth * scale}px`;
        img.style.height = `${img.naturalHeight * scale}px`;
        img.style.left = `${(-Number(region.x) - frameX) * scale}px`;
        img.style.top = `${(-Number(region.y) - frameY) * scale}px`;
      };
      img.addEventListener('load', applyCrop, { once: true });
      img.src = info.url;
      if (img.complete) applyCrop();
      viewport.appendChild(img);
      return viewport;
    } catch (error) {
      warn('PATTERN_ICON_FAILED', error);
      return null;
    } finally {
      prepared?.release?.();
    }
  }

  function getBattleOrder(heroId) {
    try {
      const description = getUnitDescription(heroId);
      const value = description ? callSemantic(description, 'get_battleOrder', { optional: true }) : null;
      return Number.isFinite(Number(value)) ? Number(value) : Number.MAX_SAFE_INTEGER;
    } catch (error) {
      warn(`BATTLE_ORDER_FAILED_${heroId}`, error);
      return Number.MAX_SAFE_INTEGER;
    }
  }

  function findSubTexture(root, { maxDepth = 5, preferSize = null } = {}) {
    if (!root || typeof root !== 'object') return null;
    const queue = [{ value: root, depth: 0 }];
    const seen = new Set();
    const found = [];
    while (queue.length) {
      const { value, depth } = queue.shift();
      if (!value || typeof value !== 'object' || seen.has(value)) continue;
      seen.add(value);
      if (className(value) === CLASS.subTexture) {
        found.push(value);
        continue;
      }
      if (depth >= maxDepth) continue;
      for (const child of Object.values(value)) {
        if (!child || typeof child !== 'object' || child instanceof Node || ArrayBuffer.isView(child) || child instanceof ArrayBuffer) continue;
        if (Array.isArray(child)) {
          for (const nested of child) if (nested && typeof nested === 'object') queue.push({ value: nested, depth: depth + 1 });
        } else {
          queue.push({ value: child, depth: depth + 1 });
        }
      }
    }
    if (!found.length) return null;
    if (!preferSize) return found[0];
    return found.find(texture => {
      const region = callSemantic(texture, 'get_region', { optional: true });
      return Number(region?.width) === preferSize[0] && Number(region?.height) === preferSize[1];
    }) ?? found[0];
  }

  function cropSubTextureToDataUrl(texture) {
    if (!texture || className(texture) !== CLASS.subTexture) return null;
    const region = callSemantic(texture, 'get_region', { optional: true });
    const frame = callSemantic(texture, 'get_frame', { optional: true });
    if (!region) return null;
    let root = texture;
    for (let i = 0; i < 10 && className(root) === CLASS.subTexture; i += 1) {
      const parent = callSemantic(root, 'get_parent', { optional: true });
      if (!parent || parent === root) break;
      root = parent;
    }
    const bitmapData = Object.values(root ?? {}).find(value => className(value) === CLASS.bitmapData) ?? null;
    const image = Object.values(bitmapData ?? {}).find(value => className(value) === CLASS.limeImage) ?? null;
    const buffer = Object.values(image ?? {}).find(value => className(value) === CLASS.limeImageBuffer) ?? null;
    let source = buffer ? callSemantic(buffer, 'get_src', { optional: true }) : null;

    // Some current atlas textures keep the browser CanvasImageSource behind a
    // different BitmapData wrapper. Search a small read-only object graph only
    // when the historical LimeImage/LimeImageBuffer path is absent.
    if (!source && bitmapData) {
      const queue = [{ value: bitmapData, depth: 0 }];
      const seen = new Set();
      let scanned = 0;
      while (queue.length && scanned < 80 && !source) {
        const { value, depth } = queue.shift();
        if (!value || typeof value !== 'object' || seen.has(value)) continue;
        seen.add(value);
        scanned += 1;
        const isCanvas = typeof HTMLCanvasElement !== 'undefined' && value instanceof HTMLCanvasElement;
        const isImage = typeof HTMLImageElement !== 'undefined' && value instanceof HTMLImageElement;
        const isBitmap = typeof ImageBitmap !== 'undefined' && value instanceof ImageBitmap;
        const isOffscreen = typeof OffscreenCanvas !== 'undefined' && value instanceof OffscreenCanvas;
        if (isCanvas || isImage || isBitmap || isOffscreen) {
          source = value;
          break;
        }
        if (depth >= 4) continue;
        let children = [];
        try { children = Object.values(value); } catch { continue; }
        for (const child of children) {
          if (!child || typeof child !== 'object' || child instanceof Node || ArrayBuffer.isView(child) || child instanceof ArrayBuffer) continue;
          queue.push({ value: child, depth: depth + 1 });
        }
      }
    }
    if (!source) return null;
    const outW = Math.max(1, Number(frame?.width ?? region.width));
    const outH = Math.max(1, Number(frame?.height ?? region.height));
    const canvas = document.createElement('canvas');
    canvas.width = outW;
    canvas.height = outH;
    const ctx = canvas.getContext('2d');
    if (!ctx) return null;
    ctx.drawImage(
      source,
      Number(region.x), Number(region.y), Number(region.width), Number(region.height),
      frame ? -Number(frame.x ?? 0) : 0,
      frame ? -Number(frame.y ?? 0) : 0,
      Number(region.width), Number(region.height)
    );
    return canvas.toDataURL('image/png');
  }

  function findObjectByClassLimited(root, targetClass, { maxDepth = 4, maxNodes = 80 } = {}) {
    if (!root || typeof root !== 'object') return null;
    const queue = [{ value: root, depth: 0 }];
    const seen = new Set();
    let scanned = 0;
    while (queue.length && scanned < maxNodes) {
      const { value, depth } = queue.shift();
      if (!value || typeof value !== 'object' || seen.has(value)) continue;
      seen.add(value);
      scanned += 1;
      if (className(value) === targetClass) return value;
      if (depth >= maxDepth) continue;
      for (const child of Object.values(value)) {
        if (!child || typeof child !== 'object' || child instanceof Node || ArrayBuffer.isView(child) || child instanceof ArrayBuffer) continue;
        if (Array.isArray(child)) {
          for (const nested of child) if (nested && typeof nested === 'object') queue.push({ value: nested, depth: depth + 1 });
        } else {
          queue.push({ value: child, depth: depth + 1 });
        }
      }
    }
    return null;
  }

  function getImageFileUrl(file) {
    if (!file) return '';
    const url = callSemantic(file, 'get_url', { optional: true });
    return url == null ? '' : String(url);
  }

  function getRsxImageDependency(file) {
    if (!file) return null;

    // Current build exposes the loaded RSX dependencies through JK(); this is the
    // same path that was live-confirmed for pet_icons0.png. Prefer it over source probing.
    if (typeof file.JK === 'function') {
      try {
        const dependencies = file.JK();
        if (Array.isArray(dependencies)) {
          const hit = dependencies.find(item => className(item) === 'engine.core.assets.file.ImageFile');
          if (hit) return hit;
        }
      } catch (error) {
        warn('RSX_DEPENDENCY_JK_FAILED', error);
      }
    }

    for (const value of Object.values(file)) {
      if (Array.isArray(value)) {
        const hit = value.find(item => className(item) === 'engine.core.assets.file.ImageFile');
        if (hit) return hit;
      }
    }

    return null;
  }

  function findUniqueSourceMethodOrNull(valueOrClass, predicate) {
    const matches = prototypeMethodsDeep(valueOrClass).filter(row => {
      try { return predicate(row.source, row.fn, row.name); } catch { return false; }
    });
    return matches.length === 1 ? matches[0].name : null;
  }

  function getIconAssetLifecycle(asset) {
    if (!asset) return null;

    // IconAsset itself does not expose lifecycle names semantically. Match only the
    // tiny, known wrapper methods used by AtlasTextureIconAsset / RsxIconAsset.
    // The patterns cover the current build (Ie/pl/Dk) and the two recently observed
    // variants (Je/ol/Dk and Je/ql/Jk), plus the current Je/ql/Ek wrappers.
    // If the game changes again, fail closed.
    const ready = findUniqueSourceMethodOrNull(
      asset,
      (source, fn) => fn.length === 0 &&
        /return this\.[A-Za-z0-9_$]+(?:\(\))?\.(?:Ie|Je)\(\)/.test(source)
    );
    const acquire = findUniqueSourceMethodOrNull(
      asset,
      source => /this\.[A-Za-z0-9_$]+(?:\(\))?\.(?:pl|ol|ql)\((?:this|[A-Za-z0-9_$]+)\)/.test(source)
    );
    const release = findUniqueSourceMethodOrNull(
      asset,
      source => /this\.[A-Za-z0-9_$]+(?:\(\))?\.(?:Dk|Jk|Ek)\((?:this|[A-Za-z0-9_$]+)\)/.test(source)
    );

    return ready && acquire && release ? { ready, acquire, release } : null;
  }

  async function ensureIconAssetReady(asset, numericId) {
    const lifecycle = getIconAssetLifecycle(asset);
    if (!lifecycle) return { ready: false, release: null };

    try {
      if (Boolean(asset[lifecycle.ready]())) return { ready: true, release: null };
    } catch (error) {
      warn(`UNIT_ICON_READY_CHECK_FAILED_${numericId}`, error);
    }

    let acquired = false;
    try {
      const acquireFn = asset[lifecycle.acquire];
      if (typeof acquireFn !== 'function') return { ready: false, release: null };
      if (acquireFn.length === 0) acquireFn.call(asset);
      else acquireFn.call(asset, asset);
      acquired = true;
    } catch (error) {
      warn(`UNIT_ICON_ACQUIRE_FAILED_${numericId}`, error);
      return { ready: false, release: null };
    }

    const release = () => {
      if (!acquired) return;
      acquired = false;
      try {
        const releaseFn = asset[lifecycle.release];
        if (typeof releaseFn === 'function') {
          if (releaseFn.length === 0) releaseFn.call(asset);
          else releaseFn.call(asset, asset);
        }
      } catch (error) {
        warn(`UNIT_ICON_RELEASE_FAILED_${numericId}`, error);
      }
    };

    const started = Date.now();
    while (Date.now() - started <= 3000) {
      try {
        if (Boolean(asset[lifecycle.ready]())) return { ready: true, release };
      } catch {}
      await sleep(50);
    }

    release();
    return { ready: false, release: null };
  }

  function extractUnitIconSpriteSpec(asset) {
    const renderMethod = findMethodBySource(
      asset,
      (source, fn) => fn.length <= 1 && (
        (source.includes('switch(a)') && source.includes('this.rc()')) ||
        (source.includes('switch(a)') && source.includes('this.sc()')) ||
        source.includes('.data.jk(') ||
        source.includes('.data.kk(')
      ),
      'ICON_RENDER_METHOD_NOT_FOUND'
    );
    const rendered = asset[renderMethod](0);
    try {
      const texture = findSubTexture(rendered, { maxDepth: 4 });
      if (!texture) return null;
      const region = callSemantic(texture, 'get_region', { optional: true });
      const frame = callSemantic(texture, 'get_frame', { optional: true });
      if (!region) return null;

      let imageFile = null;
      let url = '';
      if (className(asset) === 'game.assets.icon.AtlasTextureIconAsset') {
        let atlas = callSemantic(asset, 'get_file', { optional: true }) ?? null;
        if (!atlas) {
          try {
            const atlasMethod = findMethodBySource(
              asset,
              (source, fn) => fn.length === 0 && /return [A-Za-z0-9_$.]+\.(?:zpb|wpb|bpb)\(this\./.test(source),
              'ICON_ATLAS_METHOD_NOT_FOUND'
            );
            atlas = asset[atlasMethod]();
          } catch {}
        }
        imageFile = atlas?.image ?? findObjectByClassLimited(
          atlas,
          'engine.core.assets.file.ImageFile',
          { maxDepth: 3, maxNodes: 50 }
        );
        url = getImageFileUrl(imageFile);

        // The current client no longer exposes the Hero atlas through the old
        // get_file()/ImageFile route. The same AtlasTextureIconAsset still exposes
        // its IconAtlasAsset bundle, so recover the production PNG URL from that
        // read-only bundle exactly as Pattern icons already do.
        if (!url) url = getGenericAtlasInfo(asset)?.url ?? '';
      } else if (className(asset) === 'game.assets.RsxIconAsset') {
        const file = callSemantic(asset, 'get_file', { optional: true });
        imageFile = getRsxImageDependency(file);
        url = getImageFileUrl(imageFile);
      } else {
        imageFile = findObjectByClassLimited(
          asset,
          'engine.core.assets.file.ImageFile',
          { maxDepth: 4, maxNodes: 80 }
        );
        url = getImageFileUrl(imageFile);
      }

      if (!url) return null;
      return {
        url,
        region: {
          x: Number(region.x), y: Number(region.y),
          width: Number(region.width), height: Number(region.height),
        },
        frame: frame ? {
          x: Number(frame.x ?? 0), y: Number(frame.y ?? 0),
          width: Number(frame.width), height: Number(frame.height),
        } : null,
      };
    } finally {
      try { rendered?.dispose?.(); } catch {}
    }
  }

  async function getUnitIconSpriteSpec(id) {
    const numericId = Number(id);
    if (!Number.isFinite(numericId)) return null;
    if (unitIconSpecCache.has(numericId)) return unitIconSpecCache.get(numericId);
    if (unitIconSpecPromiseCache.has(numericId)) return unitIconSpecPromiseCache.get(numericId);

    const promise = (async () => {
      try {
        const description = getUnitDescription(numericId);
        if (!description) return null;
        const EntryVO = findClass(CLASS.heroEntryVO);
        const entry = new EntryVO(description, null);
        const asset = callSemantic(entry, 'get_iconAsset', { optional: true });
        if (!asset) return null;

        // Fast path: assets already present in the current game state need no extra work.
        try {
          const immediate = extractUnitIconSpriteSpec(asset);
          if (immediate) {
            unitIconSpecCache.set(numericId, immediate);
            return immediate;
          }
        } catch {}

        // Reference data can mention units that are not currently visible. Ask the game
        // asset itself to load only that icon, wait for readiness, then extract the native
        // atlas/RSX region. No Pet-tab switching or blanket pre-cache is needed.
        const prepared = await ensureIconAssetReady(asset, numericId);
        if (!prepared.ready) return null;
        try {
          const spec = extractUnitIconSpriteSpec(asset);
          if (!spec) return null;
          unitIconSpecCache.set(numericId, spec);
          return spec;
        } finally {
          prepared.release?.();
        }
      } catch (error) {
        warn(`UNIT_ICON_SPEC_FAILED_${numericId}`, error);
        return null;
      }
    })();

    unitIconSpecPromiseCache.set(numericId, promise);
    try {
      return await promise;
    } finally {
      unitIconSpecPromiseCache.delete(numericId);
    }
  }

  function getImageSize(url) {
    if (imageSizeCache.has(url)) return imageSizeCache.get(url);
    const promise = new Promise(resolve => {
      const image = new Image();
      image.onload = () => resolve({ width: image.naturalWidth, height: image.naturalHeight });
      image.onerror = () => resolve(null);
      image.src = url;
    });
    imageSizeCache.set(url, promise);
    return promise;
  }

  async function createUnitSprite(id, size, classNameValue = 'unit-sprite', title = '') {
    const spec = await getUnitIconSpriteSpec(id);
    if (!spec) return null;
    const imageSize = await getImageSize(spec.url);
    if (!imageSize?.width || !imageSize?.height) return null;

    const logicalWidth = Number(spec.frame?.width ?? spec.region.width) || 1;
    const logicalHeight = Number(spec.frame?.height ?? spec.region.height) || 1;
    const scale = Math.min(size / logicalWidth, size / logicalHeight);
    const frameX = Number(spec.frame?.x ?? 0);
    const frameY = Number(spec.frame?.y ?? 0);

    const element = document.createElement('span');
    element.className = classNameValue;
    element.title = title || String(id);
    element.style.width = `${size}px`;
    element.style.height = `${size}px`;
    element.style.backgroundImage = `url("${spec.url}")`;
    element.style.backgroundRepeat = 'no-repeat';
    element.style.backgroundSize = `${imageSize.width * scale}px ${imageSize.height * scale}px`;
    element.style.backgroundPosition = `${(-spec.region.x - frameX) * scale}px ${(-spec.region.y - frameY) * scale}px`;
    return element;
  }


  async function getIconAssetSpriteSpec(asset, diagnosticId = 'asset') {
    if (!asset) return null;
    try {
      const immediate = extractUnitIconSpriteSpec(asset);
      if (immediate) return immediate;
    } catch {}

    const prepared = await ensureIconAssetReady(asset, diagnosticId);
    if (!prepared.ready) return null;
    try {
      return extractUnitIconSpriteSpec(asset);
    } finally {
      prepared.release?.();
    }
  }

  async function getWarFlagSpriteSpec(id) {
    const numericId = Number(id);
    if (!Number.isFinite(numericId) || numericId <= 0) return null;
    try {
      // rc18 incorrectly reused the Pattern-only mapper for BannerDescription.
      // The live client exposes War Flag bodies through InventoryAssetStorage's
      // BannerDescription -> atlas SubTexture wrapper instead. Crop that native
      // texture to a self-contained PNG, then feed the normal sprite renderer.
      const texture = getNativeWarFlagTexture(numericId);
      if (texture) {
        const region = callSemantic(texture, 'get_region', { optional: true });
        const frame = callSemantic(texture, 'get_frame', { optional: true });
        const dataUrl = cropSubTextureToDataUrl(texture);
        if (dataUrl) {
          const width = Math.max(1, Number(frame?.width ?? region?.width ?? 1));
          const height = Math.max(1, Number(frame?.height ?? region?.height ?? 1));
          return {
            url: dataUrl,
            region: { x: 0, y: 0, width, height },
            frame: null,
          };
        }
      }

      // Fail-soft fallback: if a future build maps BannerDescription to a directly
      // renderable IconAsset again, allow the generic native asset route.
      const description = getBannerDescription(numericId);
      const AssetUtil = findClass(CLASS.assetStorageUtil);
      const method = getPatternAssetMapperMethod();
      const asset = description ? (AssetUtil[method](description) ?? null) : null;
      if (asset) return await getIconAssetSpriteSpec(asset, `flag:${numericId}`);
      return null;
    } catch (error) {
      warn(`WAR_FLAG_SPRITE_FAILED_${numericId}`, error);
      return null;
    }
  }

  function getWarFlagDisplayName(id) {
    const numericId = Number(id);
    if (!Number.isFinite(numericId) || numericId <= 0) return '';
    try {
      const description = getBannerDescription(numericId);
      const name = description ? callSemantic(description, 'get_name', { optional: true }) : null;
      if (name != null && String(name).trim()) return String(name).trim();
    } catch {}
    return warFlagIdText(numericId);
  }

  async function createSpriteFromSpec(spec, size, classNameValue = 'unit-sprite', title = '') {
    if (!spec?.url) return null;
    const imageSize = await getImageSize(spec.url);
    if (!imageSize?.width || !imageSize?.height) return null;
    const logicalWidth = Number(spec.frame?.width ?? spec.region.width) || 1;
    const logicalHeight = Number(spec.frame?.height ?? spec.region.height) || 1;
    const scale = Math.min(size / logicalWidth, size / logicalHeight);
    const frameX = Number(spec.frame?.x ?? 0);
    const frameY = Number(spec.frame?.y ?? 0);
    const element = document.createElement('span');
    element.className = classNameValue;
    element.title = title;
    element.style.display = 'block';
    element.style.width = `${size}px`;
    element.style.height = `${size}px`;
    element.style.backgroundImage = `url("${spec.url}")`;
    element.style.backgroundRepeat = 'no-repeat';
    element.style.backgroundSize = `${imageSize.width * scale}px ${imageSize.height * scale}px`;
    element.style.backgroundPosition = `${(-spec.region.x - frameX) * scale}px ${(-spec.region.y - frameY) * scale}px`;
    element.style.flex = '0 0 auto';
    return element;
  }

  function getRendererData(renderer) {
    const data = callSemantic(renderer, 'get_data', { optional: true });
    if (data) return data;
    const SlotVO = findClass(CLASS.cowSlotVO);
    return Object.values(renderer ?? {}).find(value => value instanceof SlotVO) ?? null;
  }

  function findCowSlotRendererFast(snapshot, slotNumber) {
    const Renderer = findClass(CLASS.cowAttackRenderer, { optional: true });
    if (!Renderer || !snapshot?.popup) return null;
    const queue = [{ value: snapshot.popup, depth: 0 }];
    const seen = new Set();
    let scanned = 0;
    while (queue.length && scanned < FLAG_SCAN_LIMIT) {
      const { value, depth } = queue.shift();
      if (!value || typeof value !== 'object' || seen.has(value)) continue;
      seen.add(value);
      scanned += 1;
      if (value instanceof Renderer) {
        const data = getRendererData(value);
        if (Number(callSemantic(data, 'get_slotNumber', { optional: true })) === Number(slotNumber)) return value;
        continue;
      }
      if (depth >= 4) continue;
      for (const child of Object.values(value)) {
        if (!child || typeof child !== 'object' || child instanceof Node || ArrayBuffer.isView(child) || child instanceof ArrayBuffer) continue;
        if (Array.isArray(child)) {
          for (const nested of child) if (nested && typeof nested === 'object') queue.push({ value: nested, depth: depth + 1 });
        } else {
          queue.push({ value: child, depth: depth + 1 });
        }
      }
    }
    return null;
  }

  function captureCurrentFlagBody(snapshot, slotNumber) {
    const renderer = findCowSlotRendererFast(snapshot, slotNumber);
    if (!renderer) return null;
    const clip = renderer.clip ?? Object.values(renderer).find(value => value && typeof value === 'object' && className(value)?.endsWith('Clip'));
    const bannerComponent = clip?.He ?? Object.values(clip ?? {}).find(value => className(value) === 'game.view.gui.components.banner.MiniBannerClipWithTooltip');
    if (!bannerComponent) return null;
    const texture = findSubTexture(bannerComponent, { maxDepth: 7, preferSize: [84, 84] });
    return cropSubTextureToDataUrl(texture);
  }

  function findDefenseEditorOpenMethod(demo) {
    return findUniqueSourceMethodOrNull(
      demo,
      (source, fn) => {
        if (fn.length !== 1) return false;
        return (
          source.includes('this.player') &&
          /new [A-Za-z0-9_$]+\(this\.player,/.test(source) &&
          /\.open\(\)/.test(source) &&
          /\.close\(\)/.test(source) &&
          /null==[A-Za-z0-9_$]+&&\([A-Za-z0-9_$]+=!1\)/.test(source)
        );
      }
    );
  }

  async function openDefenseEditorFailSoft(demo) {
    try {
      await waitFor(() => hasOpenDemoBattle(), {
        timeout: 2500,
        step: 70,
        code: 'DEMO_POPUP_OPEN_TIMEOUT',
      });

      const methodName = findDefenseEditorOpenMethod(demo);
      if (!methodName || typeof demo?.[methodName] !== 'function') {
        warn('DEFENSE_EDITOR_METHOD_NOT_FOUND');
        return false;
      }

      log('Opening native Defense editor', { methodName });
      demo[methodName](false);

      // Confirm the same native editor that the pencil button opens.
      await waitFor(
        () => getOpenPopupsByClass(CLASS.demoDefenseGatherPopup).length === 1,
        { timeout: 2500, step: 70, code: 'DEFENSE_EDITOR_OPEN_TIMEOUT' }
      );
      return true;
    } catch (error) {
      warn('DEFENSE_EDITOR_AUTO_OPEN_FAILED', error);
      return false;
    }
  }

  function formatDefenseLabel(value) {
    if (!value) return t('currentDefense');
    const building = value.building || t('defense');
    const slot = value.slotNumber != null ? ` #${value.slotNumber}` : '';
    const player = value.playerName ? ` · ${value.playerName}` : '';
    return `${building}${slot}${player}`;
  }

  function installEightWayResize({ host, panel, shadow, minWidth, minHeight = PANEL_MIN_HEIGHT, onResize, onResizeEnd }) {
    const directions = ['n', 'ne', 'e', 'se', 's', 'sw', 'w', 'nw'];
    const handles = [];
    let active = null;

    for (const direction of directions) {
      const handle = document.createElement('div');
      handle.className = `resize-handle resize-${direction}`;
      handle.dataset.direction = direction;
      panel.appendChild(handle);
      handles.push(handle);

      handle.addEventListener('pointerdown', event => {
        if (event.button !== 0) return;
        const rect = panel.getBoundingClientRect();
        active = {
          id: event.pointerId,
          direction,
          startX: event.clientX,
          startY: event.clientY,
          left: rect.left,
          top: rect.top,
          width: rect.width,
          height: rect.height,
          right: rect.right,
          bottom: rect.bottom,
        };
        panel.style.width = `${Math.round(rect.width)}px`;
        panel.style.height = `${Math.round(rect.height)}px`;
        handle.setPointerCapture?.(event.pointerId);
        event.preventDefault();
        event.stopPropagation();
      });

      handle.addEventListener('pointermove', event => {
        if (!active || event.pointerId !== active.id || active.direction !== direction) return;
        const dx = event.clientX - active.startX;
        const dy = event.clientY - active.startY;
        const d = active.direction;

        let left = active.left;
        let top = active.top;
        let width = active.width;
        let height = active.height;

        const responsiveMinWidth = getResponsivePanelMinWidth(minWidth);

        if (d.includes('e')) {
          const maxWidth = Math.max(1, window.innerWidth - active.left);
          const localMinWidth = Math.min(responsiveMinWidth, maxWidth);
          width = clamp(active.width + dx, localMinWidth, maxWidth);
        }
        if (d.includes('s')) {
          height = clamp(active.height + dy, minHeight, Math.max(minHeight, window.innerHeight - active.top));
        }
        if (d.includes('w')) {
          const localMinWidth = Math.min(responsiveMinWidth, Math.max(1, active.right));
          left = clamp(active.left + dx, 0, Math.max(0, active.right - localMinWidth));
          width = active.right - left;
        }
        if (d.includes('n')) {
          top = clamp(active.top + dy, 0, active.bottom - minHeight);
          height = active.bottom - top;
        }

        width = Math.min(width, window.innerWidth - left);
        height = Math.min(height, window.innerHeight - top);
        host.style.left = `${Math.round(left)}px`;
        host.style.top = `${Math.round(top)}px`;
        panel.style.width = `${Math.round(width)}px`;
        panel.style.height = `${Math.round(height)}px`;
        onResize?.({ left, top, width, height });
      });

      const finish = event => {
        if (!active || event.pointerId !== active.id || active.direction !== direction) return;
        const rect = panel.getBoundingClientRect();
        active = null;
        onResizeEnd?.({ left: rect.left, top: rect.top, width: rect.width, height: rect.height });
      };
      handle.addEventListener('pointerup', finish);
      handle.addEventListener('pointercancel', finish);
    }

    return () => {
      for (const handle of handles) handle.remove();
    };
  }

  function createPatronReferenceView() {
    document.getElementById(PATRON_HOST_ID)?.remove();
    const origin = mainPanelController?.getPosition?.() ?? { left: 8, top: 8 };
    const uiState = loadUiState();
    const host = document.createElement('div');
    host.id = PATRON_HOST_ID;
    host.style.cssText = `position:fixed;left:${Math.round(origin.left)}px;top:${Math.round(origin.top)}px;z-index:2147483647;pointer-events:auto;user-select:none;`;
    const shadow = host.attachShadow({ mode: 'open' });
    shadow.innerHTML = `
      <style>
        :host { all: initial; }
        * { box-sizing:border-box; }
        .panel { position:relative; width:410px; border:1px solid rgba(255,255,255,.18); border-radius:9px; overflow:visible;
          display:flex; flex-direction:column; background:rgba(23,25,30,.97); color:#f4f4f4; font:var(--hwct-font-size, 13px)/1.34 Arial,sans-serif; box-shadow:0 5px 18px rgba(0,0,0,.42); }
        #content { flex:1 1 auto; min-height:0; overflow:auto; }
        .head { position:relative; display:flex; align-items:center; min-height:34px; padding:5px 7px 5px 9px; background:rgba(255,255,255,.055);
          font-weight:700; font-size:1.02em; cursor:grab; touch-action:none; }
        .head.dragging { cursor:grabbing; }
        .head-title { flex:1; min-width:0; display:flex; align-items:center; }
        .tool-mark { flex:0 0 auto; margin-right:4px; color:#e3b65f; font-size:1.55em; line-height:.8; text-shadow:0 0 4px rgba(227,182,95,.28); }
        .head-action { width:25px; height:23px; border:0; border-radius:4px; background:transparent; color:#bbb; cursor:pointer; font:700 1.05em Arial; padding:0; }
        .head-action:hover { background:rgba(255,255,255,.08); color:#fff; }
        .settings { position:absolute; right:31px; top:31px; z-index:3; min-width:166px; padding:9px; border:1px solid rgba(255,255,255,.2);
          border-radius:7px; background:rgba(29,32,39,.99); box-shadow:0 6px 18px rgba(0,0,0,.5); cursor:default; }
        .settings-title { font-weight:700; margin-bottom:7px; }
        .font-controls { display:grid; grid-template-columns:30px 1fr 30px; gap:6px; align-items:center; }
        .font-controls button, .settings-save, .settings-reset { border:1px solid rgba(255,255,255,.18); border-radius:5px; background:rgba(255,255,255,.07); color:#eee; cursor:pointer; padding:4px 6px; }
        .font-value { text-align:center; color:#ddd; }
        .settings-save, .settings-reset { width:100%; margin-top:7px; }
        .settings-save { background:rgba(221,177,94,.18); border-color:rgba(240,199,120,.5); }
        .target { padding:7px 9px 8px; border-top:1px solid rgba(255,255,255,.08); border-bottom:1px solid rgba(255,255,255,.08); background:rgba(221,177,94,.10); color:#fff; }
        .target-primary { font-weight:800; font-size:1.10em; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
        .target-secondary { margin-top:1px; font-weight:700; font-size:.96em; color:#e6e6e6; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
        .section { padding:6px 6px 7px; }
        .section + .section { border-top:1px solid rgba(255,255,255,.12); }
        .label { font-weight:700; margin-bottom:2px; }
        .meta { font-weight:700; color:#eee; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
        .sub { color:#bdbdbd; margin-bottom:5px; }
        .icons { display:flex; align-items:center; gap:calc(5px * var(--hwct-icon-scale, 1)); min-height:calc(42px * var(--hwct-icon-scale, 1)); }
        .iconbox { width:calc(40px * var(--hwct-icon-scale, 1)); height:calc(40px * var(--hwct-icon-scale, 1)); border-radius:7px; display:flex; align-items:center; justify-content:center;
          border:1px solid rgba(255,255,255,.13); background:rgba(255,255,255,.025); overflow:hidden; position:relative; flex:0 0 auto; }
        .iconbox.hero { width:calc(46px * var(--hwct-icon-scale, 1)); height:calc(46px * var(--hwct-icon-scale, 1)); border:0; background:transparent; overflow:visible; }
        .unit-sprite { display:block; flex:0 0 auto; border-radius:50%; overflow:hidden; transform:scale(var(--hwct-icon-scale, 1)); transform-origin:center center; }
        .hero-sprite { position:absolute; inset:0; display:block; border-radius:50%; overflow:hidden; transform:scale(var(--hwct-icon-scale, 1)); transform-origin:top left; }
        .patron-sprite { position:absolute; right:-1px; bottom:-1px; z-index:6; display:block; border-radius:50%; overflow:hidden; transform:scale(var(--hwct-icon-scale, 1)); transform-origin:bottom right;
          background-color:#111; border:1px solid rgba(255,255,255,.82); box-shadow:0 1px 3px rgba(0,0,0,.75); }
        .flag-img { width:100%; height:100%; object-fit:contain; display:block; }
        .flag-sprite { display:block; flex:0 0 auto; }
        .placeholder { color:#aeb5c0; font:700 .82em Arial; text-align:center; padding:2px; }
        .used-head { display:flex; align-items:center; gap:4px; margin-bottom:4px; }
        .used-head .label { flex:1; margin:0; }
        .label-line { display:flex; align-items:center; gap:5px; font-weight:700; margin-bottom:3px; }
        .info { position:relative; z-index:auto; display:inline-flex; align-items:center; justify-content:center; width:16px; height:16px; border:0; padding:0; border-radius:50%; background:transparent; color:#9fc8ff; cursor:help; font:700 .92em Arial; }
        /* Overlay stacking rule: inactive tooltip triggers must not create sibling stacking contexts.
           Elevate only the active trigger so its floating tooltip cannot be covered by another info icon. */
        .info.tip-open, .info:hover, .info:focus, .info:focus-within { z-index:60; }
        .info-tip { position:fixed; left:8px; top:8px; z-index:2147483647; width:min(360px, calc(100vw - 16px)); max-height:calc(100vh - 16px); overflow:auto; overscroll-behavior:contain; padding:8px 9px; border:1px solid rgba(255,255,255,.2); border-radius:7px; background:rgb(16,18,23); color:#eee; font-weight:400; line-height:1.45; box-shadow:0 5px 17px rgba(0,0,0,.55); white-space:normal; pointer-events:auto; user-select:text; display:none; }
        .info:hover .info-tip, .info:focus-within .info-tip, .info:focus .info-tip, .info.tip-open .info-tip { display:block; }
        .last { color:#bdbdbd; margin-left:auto; white-space:nowrap; font-size:.92em; font-weight:400; }
        .past-last-inline { margin-left:7px; }
        .current-row { display:flex; align-items:flex-start; justify-content:flex-start; gap:calc(5px * var(--hwct-icon-scale, 1)); }
        .current-cell { width:calc(58px * var(--hwct-icon-scale, 1)); flex:0 0 auto; min-width:0;
          display:flex; flex-direction:column; align-items:center; gap:calc(3px * var(--hwct-icon-scale, 1)); }
        .current-icon { width:calc(58px * var(--hwct-icon-scale, 1)); height:calc(58px * var(--hwct-icon-scale, 1));
          border-radius:calc(8px * var(--hwct-icon-scale, 1)); display:flex; align-items:center; justify-content:center; overflow:hidden;
          border:1px solid rgba(255,255,255,.13); background:rgba(255,255,255,.025); position:relative; box-sizing:border-box; }
        .current-icon .flag-img { width:calc(54px * var(--hwct-icon-scale, 1)); height:calc(54px * var(--hwct-icon-scale, 1)); object-fit:contain; }
        .current-icon.pattern-frame {
          border-width:calc(3px * var(--hwct-icon-scale, 1));
          border-style:solid;
          border-color:var(--pattern-frame, #777);
          box-shadow:inset 0 0 0 1px rgba(255,255,255,.24), 0 0 4px color-mix(in srgb, var(--pattern-frame, #777) 55%, transparent);
        }
        .current-icon.pattern-tier-white    { --pattern-frame:#9fa8b4; }
        .current-icon.pattern-tier-green    { --pattern-frame:#32c653; }
        .current-icon.pattern-tier-blue     { --pattern-frame:#397cf3; }
        .current-icon.pattern-tier-violet   { --pattern-frame:#bd3be3; }
        .current-icon.pattern-tier-orange   { --pattern-frame:#e89a13; }
        .current-icon.pattern-tier-red      { --pattern-frame:#f04455; }
        .current-icon.pattern-tier-ultimate { --pattern-frame:#c51636; }
        .pattern-viewport { position:relative; overflow:hidden; flex:0 0 auto;
          transform:scale(var(--hwct-icon-scale, 1)); transform-origin:center center; }
        .pattern-atlas-img { position:absolute; max-width:none; max-height:none; }
        .pattern-value { min-height:16px; color:#e8e8e8; font-weight:700; font-size:.86em; text-align:center; white-space:nowrap; }
        .asset-fallback-text { margin-top:4px; color:#d7d7d7; font-size:.82em; line-height:1.28; white-space:normal; overflow-wrap:anywhere; user-select:text; }
        .asset-fallback-text strong { color:#f0f0f0; }
        .past-head { display:flex; align-items:center; gap:4px; margin-bottom:3px; }
        .past-head .label-line { margin:0; }
        .hero-defeated .hero-sprite { filter:grayscale(1) brightness(.52); opacity:.78; }
        .defeated-cross, .defeated-label { position:absolute; z-index:5; pointer-events:none; display:none; }
        .defeated-cross { inset:0; align-items:center; justify-content:center; color:#fff; font:900 29px/1 Arial; text-shadow:0 1px 3px #000,0 0 5px #000; }
        .defeated-label { left:1px; right:1px; bottom:2px; padding:1px 0; border-radius:3px; background:rgba(20,20,20,.80); color:#fff;
          font:700 7px/1 Arial; letter-spacing:.15px; text-align:center; }
        .panel[data-defeated-style="cross"] .hero-defeated .defeated-cross { display:flex; }
        .panel[data-defeated-style="label"] .hero-defeated .defeated-label { display:block; }
        .loading, .empty, .error { padding:10px 6px; color:#bdbdbd; }
        .error { color:#ffb6b6; }
        .resize-handle { position:absolute; z-index:20; touch-action:none; }
        .resize-n, .resize-s { left:12px; right:12px; height:7px; }
        .resize-n { top:-3px; cursor:ns-resize; } .resize-s { bottom:-3px; cursor:ns-resize; }
        .resize-e, .resize-w { top:12px; bottom:12px; width:7px; }
        .resize-e { right:-3px; cursor:ew-resize; } .resize-w { left:-3px; cursor:ew-resize; }
        .resize-ne, .resize-nw, .resize-se, .resize-sw { width:13px; height:13px; }
        .resize-ne { right:-4px; top:-4px; cursor:nesw-resize; } .resize-nw { left:-4px; top:-4px; cursor:nwse-resize; }
        .resize-se { right:-4px; bottom:-4px; cursor:nwse-resize; } .resize-sw { left:-4px; bottom:-4px; cursor:nesw-resize; }
      </style>
      <div id="panel" class="panel">
        <div id="head" class="head">
          <span id="ref-title" class="head-title"><span class="tool-mark">⚔</span><span id="ref-title-text">${t('defenseReference')} · v${VERSION}</span></span>
          <button id="ref-settings" class="head-action" type="button" title="${t('settings')}">⚙</button>
          <button id="ref-minimize" class="head-action" type="button" title="${t('minimize')}">−</button>
          <button class="head-action close" type="button" title="${t('close')}">×</button>
          <div id="ref-settings-popover" class="settings" hidden>
            <div class="settings-title">${t('fontSize')}</div>
            <div class="font-controls"><button id="ref-font-minus" type="button">−</button><div id="ref-font-value" class="font-value"></div><button id="ref-font-plus" type="button">+</button></div>
            <button id="ref-font-save" class="settings-save" type="button">${t('save')}</button>
            <button id="ref-font-reset" class="settings-reset" type="button">${t('reset')}</button>
          </div>
        </div>
        <div id="target" class="target"></div>
        <div id="content"><div class="loading">${t('loadingReference')}</div></div>
      </div>`;

    document.documentElement.appendChild(host);

    const panel = shadow.getElementById('panel');
    const head = shadow.getElementById('head');
    const titleLine = shadow.getElementById('ref-title');
    const titleText = shadow.getElementById('ref-title-text');
    const targetLine = shadow.getElementById('target');
    const content = shadow.getElementById('content');
    const settingsButton = shadow.getElementById('ref-settings');
    const settingsPopover = shadow.getElementById('ref-settings-popover');
    const fontMinus = shadow.getElementById('ref-font-minus');
    const fontPlus = shadow.getElementById('ref-font-plus');
    const fontSave = shadow.getElementById('ref-font-save');
    const fontReset = shadow.getElementById('ref-font-reset');
    const fontValue = shadow.getElementById('ref-font-value');
    panel.dataset.defeatedStyle = defeatedDisplayStyle;
    let referenceUserMoved = Number.isFinite(uiState.refPanelX) && Number.isFinite(uiState.refPanelY);
    let currentTargetKind = null;
    let renderSequence = 0;

    panel.style.width = `${getResponsivePanelWidth(uiState.refPanelWidth, REF_PANEL_DEFAULT_WIDTH, REF_PANEL_MIN_WIDTH)}px`;
    if (Number.isFinite(uiState.refPanelHeight)) {
      panel.style.height = `${Math.min(uiState.refPanelHeight, Math.max(PANEL_MIN_HEIGHT, window.innerHeight))}px`;
    }

    function updateReferenceIconScale(width = panel.getBoundingClientRect().width || REF_PANEL_DEFAULT_WIDTH) {
      const scale = clamp(Number(width) / REF_PANEL_DEFAULT_WIDTH, 0.70, 2.00);
      host.style.setProperty('--hwct-icon-scale', String(scale));
    }
    updateReferenceIconScale();

    function setFontSize(size) {
      const normalized = clamp(Math.round(Number(size) || UI_FONT_DEFAULT), UI_FONT_MIN, UI_FONT_MAX);
      host.style.setProperty('--hwct-font-size', `${normalized}px`);
      fontValue.textContent = `${normalized}px`;
    }

    function positionInfoTip(info) {
      const tip = info?.querySelector?.('.info-tip');
      if (!tip) return;
      const anchor = info.getBoundingClientRect();
      const margin = 8;
      const gap = 6;
      const wasDisplay = tip.style.display;
      const wasVisibility = tip.style.visibility;
      tip.style.visibility = 'hidden';
      tip.style.display = 'block';
      tip.style.left = `${margin}px`;
      tip.style.top = `${margin}px`;
      const tipRect = tip.getBoundingClientRect();
      let left = anchor.left;
      let top = anchor.bottom + gap;
      if (top + tipRect.height > window.innerHeight - margin) {
        top = anchor.top - tipRect.height - gap;
      }
      const maxLeft = Math.max(margin, window.innerWidth - tipRect.width - margin);
      const maxTop = Math.max(margin, window.innerHeight - tipRect.height - margin);
      left = clamp(left, margin, maxLeft);
      top = clamp(top, margin, maxTop);
      tip.style.left = `${Math.round(left)}px`;
      tip.style.top = `${Math.round(top)}px`;
      tip.style.visibility = wasVisibility;
      tip.style.display = wasDisplay;
    }

    function bindInfoTip(info) {
      const tip = info?.querySelector?.('.info-tip');
      if (!tip) return;
      let closeTimer = null;
      const cancelClose = () => {
        if (closeTimer != null) {
          clearTimeout(closeTimer);
          closeTimer = null;
        }
      };
      const open = () => {
        cancelClose();
        info.classList.add('tip-open');
        requestAnimationFrame(() => positionInfoTip(info));
      };
      const scheduleClose = () => {
        cancelClose();
        closeTimer = setTimeout(() => {
          closeTimer = null;
          if (!info.matches(':focus-within') && !tip.matches(':hover')) {
            info.classList.remove('tip-open');
          }
        }, 250);
      };
      info.addEventListener('pointerenter', open);
      info.addEventListener('pointerleave', scheduleClose);
      info.addEventListener('focus', open);
      info.addEventListener('blur', scheduleClose);
      tip.addEventListener('pointerenter', open);
      tip.addEventListener('pointerleave', scheduleClose);
    }

    function setReferenceTarget(target) {
      currentTargetKind = target?.kind ?? null;
      const closeButton = shadow.querySelector('.close');
      if (closeButton) closeButton.style.display = currentTargetKind === 'CoW' ? 'none' : '';
      titleText.textContent = `${t('defenseReference')} · v${VERSION}`;
      targetLine.innerHTML = '';
      const primary = document.createElement('div');
      primary.className = 'target-primary';
      const building = target?.building || t('defense');
      const slot = target?.slotNumber != null ? ` #${target.slotNumber}` : '';
      primary.textContent = `${building}${slot}`;
      const secondary = document.createElement('div');
      secondary.className = 'target-secondary';
      secondary.textContent = target?.playerName || '—';
      targetLine.append(primary, secondary);
    }
    setFontSize(mainPanelController?.getFontSize?.() ?? uiState.fontSize);

    settingsButton.addEventListener('click', event => {
      event.stopPropagation();
      settingsPopover.hidden = !settingsPopover.hidden;
    });
    fontMinus.addEventListener('click', () => mainPanelController?.setFontSize?.((mainPanelController?.getFontSize?.() ?? UI_FONT_DEFAULT) - 1));
    fontPlus.addEventListener('click', () => mainPanelController?.setFontSize?.((mainPanelController?.getFontSize?.() ?? UI_FONT_DEFAULT) + 1));
    fontSave.addEventListener('click', () => { settingsPopover.hidden = true; });
    fontReset.addEventListener('click', () => mainPanelController?.setFontSize?.(UI_FONT_DEFAULT));

    function getLargestCanvasRect() {
      const canvases = [...document.querySelectorAll('canvas')]
        .map(canvas => ({ canvas, rect: canvas.getBoundingClientRect() }))
        .filter(row => row.rect.width > 300 && row.rect.height > 250);
      canvases.sort((a, b) => (b.rect.width * b.rect.height) - (a.rect.width * a.rect.height));
      return canvases[0]?.rect ?? null;
    }

    function saveReferencePosition() {
      const rect = panel.getBoundingClientRect();
      const maxX = Math.max(1, window.innerWidth - rect.width);
      const maxY = Math.max(1, window.innerHeight - rect.height);
      uiState.refPanelX = clamp(rect.left / maxX, 0, 1);
      uiState.refPanelY = clamp(rect.top / maxY, 0, 1);
      saveUiState(uiState);
    }

    function placeSavedReferencePosition() {
      if (!Number.isFinite(uiState.refPanelX) || !Number.isFinite(uiState.refPanelY)) return false;
      const rect = panel.getBoundingClientRect();
      const maxX = Math.max(0, window.innerWidth - rect.width);
      const maxY = Math.max(0, window.innerHeight - rect.height);
      host.style.left = `${Math.round(maxX * uiState.refPanelX)}px`;
      host.style.top = `${Math.round(maxY * uiState.refPanelY)}px`;
      return true;
    }

    function placeReferenceNearGamePopup() {
      if (referenceUserMoved || host.style.display === 'none') return;
      const rect = panel.getBoundingClientRect();
      const canvas = getLargestCanvasRect();
      const centerX = canvas ? canvas.left + canvas.width / 2 : window.innerWidth / 2;
      const centerY = canvas ? canvas.top + canvas.height / 2 : window.innerHeight / 2;
      const estimatedPopupHalfWidth = 390;
      const gap = 12;
      const rightCandidate = centerX + estimatedPopupHalfWidth + gap;
      const leftCandidate = centerX - estimatedPopupHalfWidth - gap - rect.width;
      let left;
      if (rightCandidate + rect.width <= window.innerWidth) left = rightCandidate;
      else if (leftCandidate >= 0) left = leftCandidate;
      else left = Math.max(0, window.innerWidth - rect.width - 8);
      left = clamp(left, 0, Math.max(0, window.innerWidth - rect.width));
      const top = clamp(centerY - rect.height / 2, 0, Math.max(0, window.innerHeight - rect.height));
      host.style.left = `${Math.round(left)}px`;
      host.style.top = `${Math.round(top)}px`;
    }

    function clampReferenceToViewport() {
      if (host.style.display === 'none') return;
      let rect = panel.getBoundingClientRect();
      const width = getResponsivePanelWidth(uiState.refPanelWidth, REF_PANEL_DEFAULT_WIDTH, REF_PANEL_MIN_WIDTH);
      const height = Math.min(rect.height || 180, window.innerHeight);
      if (Math.abs(width - rect.width) > 0.5) panel.style.width = `${Math.round(width)}px`;
      if (Math.abs(height - rect.height) > 0.5) panel.style.height = `${Math.round(height)}px`;
      rect = panel.getBoundingClientRect();
      updateReferenceIconScale(rect.width);
      const left = clamp(Number.parseFloat(host.style.left) || rect.left || 0, 0, Math.max(0, window.innerWidth - rect.width));
      const top = clamp(Number.parseFloat(host.style.top) || rect.top || 0, 0, Math.max(0, window.innerHeight - rect.height));
      host.style.left = `${Math.round(left)}px`;
      host.style.top = `${Math.round(top)}px`;
    }

    const removeReferenceResizeHandles = installEightWayResize({
      host, panel, shadow, minWidth: REF_PANEL_MIN_WIDTH, minHeight: PANEL_MIN_HEIGHT,
      onResize: rect => updateReferenceIconScale(rect.width),
      onResizeEnd: rect => {
        updateReferenceIconScale(rect.width);
        const latest = loadUiState();
        latest.refPanelWidth = Math.round(rect.width);
        latest.refPanelHeight = Math.round(rect.height);
        uiState.refPanelWidth = latest.refPanelWidth;
        uiState.refPanelHeight = latest.refPanelHeight;
        if (referenceUserMoved) {
          const maxX = Math.max(1, window.innerWidth - rect.width);
          const maxY = Math.max(1, window.innerHeight - rect.height);
          latest.refPanelX = clamp(rect.left / maxX, 0, 1);
          latest.refPanelY = clamp(rect.top / maxY, 0, 1);
          uiState.refPanelX = latest.refPanelX;
          uiState.refPanelY = latest.refPanelY;
        }
        saveUiState(latest);
        if (!referenceUserMoved) requestAnimationFrame(placeReferenceNearGamePopup);
      },
    });

    const handleReferenceResize = () => requestAnimationFrame(() => {
      clampReferenceToViewport();
      if (referenceUserMoved) {
        placeSavedReferencePosition();
      } else {
        placeReferenceNearGamePopup();
      }
    });
    window.addEventListener('resize', handleReferenceResize);
    requestAnimationFrame(() => {
      if (referenceUserMoved) {
        if (!placeSavedReferencePosition()) clampReferenceToViewport();
      } else {
        clampReferenceToViewport();
      }
    });

    let drag = null;
    head.addEventListener('pointerdown', event => {
      if (event.target.closest?.('.head-action, .settings') || event.button !== 0) return;
      const rect = panel.getBoundingClientRect();
      drag = { id: event.pointerId, dx: event.clientX - rect.left, dy: event.clientY - rect.top };
      head.classList.add('dragging');
      head.setPointerCapture?.(event.pointerId);
      event.preventDefault();
    });
    head.addEventListener('pointermove', event => {
      if (!drag || event.pointerId !== drag.id) return;
      const rect = panel.getBoundingClientRect();
      const left = clamp(event.clientX - drag.dx, 0, Math.max(0, window.innerWidth - rect.width));
      const top = clamp(event.clientY - drag.dy, 0, Math.max(0, window.innerHeight - rect.height));
      host.style.left = `${Math.round(left)}px`;
      host.style.top = `${Math.round(top)}px`;
    });
    const endDrag = event => {
      if (!drag || event.pointerId !== drag.id) return;
      drag = null;
      head.classList.remove('dragging');
      referenceUserMoved = true;
      saveReferencePosition();
    };
    head.addEventListener('pointerup', endDrag);
    head.addEventListener('pointercancel', endDrag);

    shadow.getElementById('ref-minimize').addEventListener('click', event => {
      event.stopPropagation();
      minimizePatronReference();
    });
    shadow.querySelector('.close').addEventListener('click', () => {
      hidePatronReference({ restoreMain: true, offerReopen: false });
    });

    async function addUnitBox(parent, id, sizeClass, title) {
      const numericId = Number(id);
      const box = document.createElement('div');
      box.className = `iconbox${sizeClass ? ` ${sizeClass}` : ''}`;
      box.title = title || String(id ?? '');
      const spriteSize = sizeClass === 'hero' ? 46 : 40;
      const sprite = Number.isFinite(numericId) && numericId > 0
        ? await createUnitSprite(numericId, spriteSize, sizeClass === 'hero' ? 'hero-sprite' : 'unit-sprite', title)
        : null;
      if (sprite) box.appendChild(sprite);
      else {
        const p = document.createElement('span');
        p.className = 'placeholder';
        p.textContent = Number.isFinite(numericId) && numericId > 0 ? String(numericId) : '−';
        box.appendChild(p);
      }
      parent.appendChild(box);
      return box;
    }

    return {
      host,
      savePosition() {
        referenceUserMoved = true;
        saveReferencePosition();
      },
      setLoading(target) {
        Promise.resolve(this.render(target, null, { loading: true }))
          .catch(error => warn('REFERENCE_LOADING_RENDER_FAILED', error));
      },
      setError(message, target = null) {
        if (!target) {
          content.innerHTML = '';
          const row = document.createElement('div');
          row.className = 'error';
          row.textContent = message || t('referenceCouldNotBeLoaded');
          content.appendChild(row);
          return;
        }
        Promise.resolve(this.render(target, null, { errorMessage: message || t('referenceCouldNotBeLoaded') }))
          .catch(error => warn('REFERENCE_ERROR_RENDER_FAILED', error));
      },
      async render(target, result, { errorMessage = '', loading = false } = {}) {
        const renderId = ++renderSequence;
        setReferenceTarget(target);

        const preloadIds = new Set(
          target?.kind === 'CoW'
            ? (result?.used?.petIds ?? []).map(Number).filter(id => Number.isFinite(id) && id > 0)
            : []
        );
        const preloadRef = result?.reference ?? null;
        if (Number(preloadRef?.mainPetId) > 0) preloadIds.add(Number(preloadRef.mainPetId));
        for (const hero of preloadRef?.heroes ?? []) {
          if (Number(hero?.heroId) > 0) preloadIds.add(Number(hero.heroId));
          if (Number(hero?.patronPetId) > 0) preloadIds.add(Number(hero.patronPetId));
        }
        const referenceFlagId = Number(preloadRef?.warFlag?.bannerId);
        const currentFlagId = Number(target?.currentDefense?.bannerId);
        const referenceFlagPromise = Number.isFinite(referenceFlagId) && referenceFlagId > 0
          ? getWarFlagSpriteSpec(referenceFlagId)
          : Promise.resolve(null);
        const currentFlagPromise = Number.isFinite(currentFlagId) && currentFlagId > 0
          ? getWarFlagSpriteSpec(currentFlagId)
          : Promise.resolve(null);
        const [, nativeFlagSpec, currentFlagSpec] = await Promise.all([
          Promise.allSettled([...preloadIds].map(id => getUnitIconSpriteSpec(id))),
          referenceFlagPromise,
          currentFlagPromise,
        ]);
        if (renderId !== renderSequence || !host.isConnected) return;

        content.innerHTML = '';

        // Current original defense: native War Flag + Pattern slots 0..2.
        const currentSection = document.createElement('div');
        currentSection.className = 'section';
        const currentLabel = document.createElement('div');
        currentLabel.className = 'label';
        currentLabel.textContent = t('currentDefense');
        currentSection.appendChild(currentLabel);
        const currentRow = document.createElement('div');
        currentRow.className = 'current-row';
        const currentDefense = target?.currentDefense ?? { bannerId: null, flagName: '', patterns: [] };

        const missingAssetText = [];

        const flagCell = document.createElement('div');
        flagCell.className = 'current-cell';
        const resolvedFlagName = currentDefense.flagName || (currentDefense.bannerId ? warFlagIdText(currentDefense.bannerId) : t('noWarFlag'));
        flagCell.title = resolvedFlagName;
        const flagIcon = document.createElement('div');
        flagIcon.className = 'current-icon';
        let flagRendered = false;
        if (currentFlagSpec) {
          const flagSprite = await createSpriteFromSpec(currentFlagSpec, 54, 'flag-sprite', flagCell.title);
          if (flagSprite) {
            flagIcon.appendChild(flagSprite);
            flagRendered = true;
          }
        }
        if (!flagRendered) {
          const p = document.createElement('span'); p.className = 'placeholder'; p.textContent = '−'; flagIcon.appendChild(p);
          if (currentDefense.bannerId) missingAssetText.push(`${t('flag')}: ${resolvedFlagName}`);
        }
        const flagValue = document.createElement('div');
        flagValue.className = 'pattern-value';
        flagValue.textContent = t('flag');
        flagCell.append(flagIcon, flagValue);
        currentRow.appendChild(flagCell);

        for (let patternSlot = 0; patternSlot < 3; patternSlot += 1) {
          const row = (currentDefense.patterns ?? []).find(item => Number(item.slot) === patternSlot) ?? null;
          const cell = document.createElement('div');
          cell.className = 'current-cell';
          const patternLabel = row?.name || (row ? `Pattern ${patternSlot + 1}` : t('patternSlotEmpty', { slot: patternSlot + 1 }));
          cell.title = patternLabel;
          const icon = document.createElement('div');
          icon.className = 'current-icon';
          if (row?.pattern && row?.colorTier) {
            icon.classList.add('pattern-frame', `pattern-tier-${row.colorTier}`);
          }
          const nativePatternIcon = row?.pattern ? await createPatternIconElement(row.pattern, 50) : null;
          if (nativePatternIcon) icon.appendChild(nativePatternIcon);
          else {
            const p = document.createElement('span'); p.className = 'placeholder'; p.textContent = row ? String(patternSlot + 1) : '−'; icon.appendChild(p);
            if (row) {
              const currentValue = row.currentValue;
              missingAssetText.push(`Pattern ${patternSlot + 1}: ${patternLabel}${currentValue == null ? '' : ` (${currentValue}%)`}`);
            }
          }
          const value = document.createElement('div');
          value.className = 'pattern-value';
          const currentValue = row?.currentValue;
          value.textContent = currentValue == null ? '' : `${currentValue}%`;
          cell.append(icon, value);
          currentRow.appendChild(cell);
        }
        currentSection.appendChild(currentRow);

        // Usability fallback: if Hero Wars changes an asset resolver again, keep the
        // defense readable. Names come from the same live Banner/Pattern descriptions;
        // this is intentionally only shown for assets that failed to render.
        if (missingAssetText.length) {
          const fallback = document.createElement('div');
          fallback.className = 'asset-fallback-text';
          for (const line of missingAssetText) {
            const row = document.createElement('div');
            row.textContent = line;
            fallback.appendChild(row);
          }
          currentSection.appendChild(fallback);
        }

        content.appendChild(currentSection);

        if (loading) {
          const loadingRow = document.createElement('div');
          loadingRow.className = 'section loading';
          loadingRow.textContent = target?.playerName ? t('loadingPlayer', { player: target.playerName }) : t('loadingReference');
          content.appendChild(loadingRow);
          requestAnimationFrame(placeReferenceNearGamePopup);
          return;
        }

        if (errorMessage) {
          const error = document.createElement('div');
          error.className = 'section error';
          error.textContent = errorMessage;
          content.appendChild(error);
          requestAnimationFrame(placeReferenceNearGamePopup);
          return;
        }

        // Historical exact-Hero-set reference.
        const refSection = document.createElement('div');
        refSection.className = 'section';
        const pastHead = document.createElement('div');
        pastHead.className = 'past-head';
        const label = document.createElement('div');
        label.className = 'label-line';
        const labelText = document.createElement('span');
        labelText.textContent = t('pastSetup');
        const matchInfo = document.createElement('button');
        matchInfo.type = 'button';
        matchInfo.className = 'info';
        matchInfo.setAttribute('aria-label', t('pastSetupInfo'));
        matchInfo.textContent = 'ⓘ';
        const matchTip = document.createElement('span');
        matchTip.className = 'info-tip';
        matchTip.textContent = t('pastSetupTip');
        matchInfo.appendChild(matchTip);
        bindInfoTip(matchInfo);
        const ref = result?.reference ?? null;
        const pastLast = document.createElement('span');
        pastLast.className = 'last past-last-inline';
        pastLast.textContent = ref?.dateString ? t('lastSeen', { date: ref.dateString }) : '';
        label.append(labelText, matchInfo, pastLast);
        pastHead.append(label);
        refSection.appendChild(pastHead);

        if (!ref) {
          const empty = document.createElement('div');
          empty.className = 'empty';
          empty.textContent = t('noMatchingBattleLog');
          refSection.appendChild(empty);
        } else {
          const pastBuilding = ref.building || '';
          const pastPosition = ref.position != null && String(ref.position) !== '' ? ` #${ref.position}` : '';
          if (pastBuilding || pastPosition) {
            const sub = document.createElement('div');
            sub.className = 'sub';
            sub.textContent = `${pastBuilding || '—'}${pastPosition}`;
            refSection.appendChild(sub);
          }

          const icons = document.createElement('div');
          icons.className = 'icons';
          const flagBox = document.createElement('div');
          flagBox.className = 'iconbox';
          if (!ref.warFlag) {
            const p = document.createElement('span'); p.className = 'placeholder'; p.textContent = '−'; flagBox.appendChild(p);
          } else if (nativeFlagSpec) {
            flagBox.title = getWarFlagDisplayName(ref.warFlag.bannerId);
            const flagSprite = await createSpriteFromSpec(nativeFlagSpec, 40, 'flag-sprite', flagBox.title);
            if (flagSprite) flagBox.appendChild(flagSprite);
            else { const p = document.createElement('span'); p.className = 'placeholder'; p.textContent = getWarFlagDisplayName(ref.warFlag.bannerId); flagBox.appendChild(p); }
          } else {
            flagBox.title = getWarFlagDisplayName(ref.warFlag.bannerId);
            const p = document.createElement('span'); p.className = 'placeholder'; p.textContent = flagBox.title; flagBox.appendChild(p);
          }
          icons.appendChild(flagBox);
          await addUnitBox(icons, ref.mainPetId, '', ref.mainPetId == null ? t('noMainPet') : t('mainPetId', { id: ref.mainPetId }));

          const sortedHeroes = ref.heroes.slice().sort((a, b) => getBattleOrder(b.heroId) - getBattleOrder(a.heroId));
          for (const hero of sortedHeroes) {
            const state = target?.heroStateById instanceof Map ? target.heroStateById.get(Number(hero.heroId)) : null;
            const defeated = state?.alive === false;
            const box = await addUnitBox(
              icons,
              hero.heroId,
              'hero',
              defeated ? t('heroDefeatedId', { id: hero.heroId }) : t('heroId', { id: hero.heroId })
            );
            if (defeated) {
              box.classList.add('hero-defeated');
              const cross = document.createElement('span');
              cross.className = 'defeated-cross';
              cross.textContent = '×';
              const defeatedLabel = document.createElement('span');
              defeatedLabel.className = 'defeated-label';
              defeatedLabel.textContent = t('defeatedUpper');
              box.append(cross, defeatedLabel);
            }
            if (Number(hero.patronPetId) > 0) {
              const patron = await createUnitSprite(Number(hero.patronPetId), 19, 'patron-sprite', patronPetIdText(hero.patronPetId));
              if (patron) box.appendChild(patron);
              else {
                const marker = document.createElement('span');
                marker.className = 'patron-sprite placeholder';
                marker.textContent = String(hero.patronPetId);
                box.appendChild(marker);
              }
            }
          }
          refSection.appendChild(icons);
        }
        content.appendChild(refSection);
        if (target?.kind === 'CoW') {
          // Used Patrons from the current matchup/day.
          const usedSection = document.createElement('div');
          usedSection.className = 'section';
          const usedHead = document.createElement('div');
          usedHead.className = 'used-head';
          const usedLabel = document.createElement('div');
          usedLabel.className = 'label-line';
          const usedText = document.createElement('span');
          usedText.textContent = t('usedPatrons');
          const usedInfo = document.createElement('button');
          usedInfo.type = 'button';
          usedInfo.className = 'info';
          usedInfo.setAttribute('aria-label', t('usedPatronsInfo'));
          usedInfo.textContent = 'ⓘ';
          const usedTip = document.createElement('span');
          usedTip.className = 'info-tip';
          usedTip.textContent = t('usedPatronsTip');
          usedInfo.appendChild(usedTip);
          bindInfoTip(usedInfo);
          usedLabel.append(usedText, usedInfo);
          const usedLast = document.createElement('div');
          usedLast.className = 'last past-last-inline';
          usedLast.textContent = result?.used?.lastSeen ? t('lastSeen', { date: result.used.lastSeen }) : '';
          usedHead.append(usedLabel, usedLast);
          usedSection.appendChild(usedHead);
          const usedIcons = document.createElement('div');
          usedIcons.className = 'icons';
          const petIds = [...new Set((result?.used?.petIds ?? []).map(Number).filter(id => Number.isFinite(id) && id > 0))];
          if (!petIds.length) {
            const none = document.createElement('span');
            none.className = 'empty';
            none.textContent = t('noPatronUse');
            usedIcons.appendChild(none);
          } else {
            for (const petId of petIds) await addUnitBox(usedIcons, petId, '', t('usedPatronId', { id: petId }));
          }
          usedSection.appendChild(usedIcons);
          content.appendChild(usedSection);
        }

        requestAnimationFrame(placeReferenceNearGamePopup);
      },
      setFontSize,
      setDefeatedStyle(style) {
        defeatedDisplayStyle = ['cross', 'label', 'gray'].includes(style) ? style : 'cross';
        panel.dataset.defeatedStyle = defeatedDisplayStyle;
      },
      getKind() { return currentTargetKind; },
      setScopeVisible(visible) {
        host.style.display = visible ? '' : 'none';
        if (visible) requestAnimationFrame(() => {
          if (referenceUserMoved) {
            if (!placeSavedReferencePosition()) clampReferenceToViewport();
          } else {
            clampReferenceToViewport();
          }
        });
      },
      remove() {
        renderSequence += 1;
        window.removeEventListener('resize', handleReferenceResize);
        removeReferenceResizeHandles?.();
        host.remove();
      },
    };
  }

  function ensurePatronReferenceView() {
    if (!patronReferenceView?.host?.isConnected) patronReferenceView = createPatronReferenceView();
    return patronReferenceView;
  }

  function getCurrentReferenceSession() {
    if (nativeCowSession?.target && nativeCowSession?.popup) {
      try {
        if (getOpenPopupsByClass(CLASS.demoPopup).includes(nativeCowSession.popup)) {
          return { session: nativeCowSession, target: nativeCowSession.target };
        }
      } catch {}
    }
    if (activeDefenseSession?.patronTarget && hasOpenTrainingUi()) {
      return { session: activeDefenseSession, target: activeDefenseSession.patronTarget };
    }
    return null;
  }

  function hideReferenceReopenLauncher() {
    referenceReopenLauncher?._hwctCleanup?.();
    referenceReopenLauncher?.remove?.();
    referenceReopenLauncher = null;
    const stale = document.getElementById(REFERENCE_REOPEN_HOST_ID);
    stale?._hwctCleanup?.();
    stale?.remove();
  }

  function showReferenceReopenLauncher() {
    const current = getCurrentReferenceSession();
    if (!current) {
      hideReferenceReopenLauncher();
      return null;
    }
    if (referenceReopenLauncher?.isConnected) return referenceReopenLauncher;

    document.getElementById(REFERENCE_REOPEN_HOST_ID)?.remove();
    const uiState = loadUiState();
    const host = document.createElement('div');
    host.id = REFERENCE_REOPEN_HOST_ID;

    function getMiniSide() {
      if (uiState.refMiniSide === 'left' || uiState.refMiniSide === 'right') return uiState.refMiniSide;
      return Number.isFinite(uiState.refPanelX) && uiState.refPanelX < 0.5 ? 'left' : 'right';
    }

    function getMiniY() {
      if (Number.isFinite(uiState.refMiniY)) return clamp(uiState.refMiniY, 0, 1);
      return Number.isFinite(uiState.refPanelY) ? clamp(uiState.refPanelY, 0, 1) : 0.25;
    }

    function placeMini(side = getMiniSide(), yRatio = getMiniY()) {
      const button = shadow?.querySelector?.('button');
      const rect = button?.getBoundingClientRect?.();
      const width = rect?.width || 44;
      const height = rect?.height || 44;
      const maxY = Math.max(0, window.innerHeight - height);
      host.style.top = `${Math.round(maxY * clamp(yRatio, 0, 1))}px`;
      if (side === 'left') {
        host.style.left = '0px';
        host.style.right = 'auto';
      } else {
        host.style.left = 'auto';
        host.style.right = '0px';
      }
    }

    host.style.cssText = [
      'position:fixed',
      'left:0',
      'top:0',
      'z-index:2147483647',
      'pointer-events:auto',
      'user-select:none'
    ].join(';') + ';';

    const shadow = host.attachShadow({ mode: 'open' });
    shadow.innerHTML = `
      <style>
        :host { all: initial; }
        .mini {
          box-sizing:border-box; width:44px; height:44px;
          border:1px solid rgba(255,255,255,.22);
          border-radius:9px;
          background:rgba(24,27,33,.96);
          color:#f1f1f1;
          cursor:grab;
          touch-action:none;
          padding:3px;
          box-shadow:0 4px 14px rgba(0,0,0,.35);
          display:flex;
          flex-direction:column;
          align-items:center;
          justify-content:center;
          gap:0;
          font-family:Arial,sans-serif;
        }
        .mini:hover { background:rgba(40,44,52,.98); color:#fff; }
        .mini.dragging { cursor:grabbing; }
        .mini-icon { font:700 27px/27px Arial; color:#e3b65f; text-shadow:0 0 4px rgba(227,182,95,.28); }
      </style>
      <button class="mini" type="button" aria-label="${t('openDefenseReference')}" title="${t('defenseReference')} v${VERSION}">
        <span class="mini-icon">⚔</span>
      </button>
    `;
    const miniButton = shadow.querySelector('button');
    let miniDrag = null;

    miniButton.addEventListener('pointerdown', event => {
      if (event.button !== 0) return;
      const rect = miniButton.getBoundingClientRect();
      // Convert right-edge anchoring to a pixel position while dragging freely.
      host.style.left = `${Math.round(rect.left)}px`;
      host.style.right = 'auto';
      host.style.top = `${Math.round(rect.top)}px`;
      miniDrag = {
        id: event.pointerId,
        dx: event.clientX - rect.left,
        dy: event.clientY - rect.top,
        startX: event.clientX,
        startY: event.clientY,
        moved: false,
      };
      miniButton.classList.add('dragging');
      miniButton.setPointerCapture?.(event.pointerId);
      event.preventDefault();
    });

    miniButton.addEventListener('pointermove', event => {
      if (!miniDrag || event.pointerId !== miniDrag.id) return;
      if (Math.hypot(event.clientX - miniDrag.startX, event.clientY - miniDrag.startY) >= 4) miniDrag.moved = true;
      if (!miniDrag.moved) return;
      const rect = miniButton.getBoundingClientRect();
      const left = clamp(event.clientX - miniDrag.dx, 0, Math.max(0, window.innerWidth - rect.width));
      const top = clamp(event.clientY - miniDrag.dy, 0, Math.max(0, window.innerHeight - rect.height));
      host.style.left = `${Math.round(left)}px`;
      host.style.right = 'auto';
      host.style.top = `${Math.round(top)}px`;
    });

    function endMiniDrag(event, cancelled = false) {
      if (!miniDrag || event.pointerId !== miniDrag.id) return;
      const moved = miniDrag.moved;
      miniDrag = null;
      miniButton.classList.remove('dragging');
      if (!moved) {
        if (!cancelled) {
          hideReferenceReopenLauncher();
          showActivePatronReference();
        } else {
          placeMini();
        }
        return;
      }

      const rect = miniButton.getBoundingClientRect();
      uiState.refMiniSide = rect.left + rect.width / 2 < window.innerWidth / 2 ? 'left' : 'right';
      uiState.refMiniY = clamp(rect.top / Math.max(1, window.innerHeight - rect.height), 0, 1);
      saveUiState(uiState);
      placeMini(uiState.refMiniSide, uiState.refMiniY);
    }

    miniButton.addEventListener('pointerup', event => endMiniDrag(event, false));
    miniButton.addEventListener('pointercancel', event => endMiniDrag(event, true));

    const handleResize = () => window.requestAnimationFrame(() => placeMini());
    window.addEventListener('resize', handleResize);
    host._hwctCleanup = () => window.removeEventListener('resize', handleResize);

    document.documentElement.appendChild(host);
    placeMini();
    referenceReopenLauncher = host;
    return host;
  }

  function minimizePatronReference() {
    patronRequestToken += 1;
    patronReferenceView?.savePosition?.();
    patronReferenceView?.remove?.();
    patronReferenceView = null;
    hideReferenceReopenLauncher();
    if (getCurrentReferenceSession()) {
      mainPanelController?.hideForReference?.();
      showReferenceReopenLauncher();
    }
  }

  function hidePatronReference({ restoreMain = true, offerReopen = false } = {}) {
    patronRequestToken += 1;
    patronReferenceView?.remove?.();
    patronReferenceView = null;
    hideReferenceReopenLauncher();
    if (restoreMain) {
      mainPanelController?.showAfterReference?.();
      if (activeDefenseSession && hasOpenTrainingUi()) {
        mainPanelController?.setStatus?.(
          t('referenceClosedReopen')
        );
      }
    }
    if (offerReopen && getCurrentReferenceSession()) showReferenceReopenLauncher();
  }

  function showActivePatronReference() {
    const current = getCurrentReferenceSession();
    if (!current) return false;
    const { session, target } = current;
    hideReferenceReopenLauncher();
    if (target?.kind === 'GW') mainPanelController?.hideForReference?.();
    const view = ensurePatronReferenceView();
    if (session.referenceResult) {
      Promise.resolve(view.render(target, session.referenceResult)).catch(error => {
        warn('PATRON_REFERENCE_RENDER_FAILED', error);
        view.setError(t('failedDisplayDefenseReference'), target);
      });
      return true;
    }
    if (session.referenceError) {
      view.setError(t('couldNotLoadBattleLogReference'), target);
      return true;
    }
    startPatronReferenceLoad(target, session);
    return true;
  }

  function startPatronReferenceLoad(target, session = activeDefenseSession) {
    hideReferenceReopenLauncher();
    const token = ++patronRequestToken;
    if (target?.kind === 'GW') mainPanelController?.hideForReference?.();
    const view = ensurePatronReferenceView();
    view.setLoading(target);
    if (session) session.referenceLoading = true;
    Promise.resolve()
      .then(() => target?.kind === 'GW'
        ? loadGwPatronReference(target)
        : target?.kind === 'CoW'
          ? loadCowPatronReference(target)
          : fail('PATRON_TARGET_KIND_UNSUPPORTED'))
      .then(async result => {
        if (session && isReferenceSessionActive(session)) {
          session.referenceResult = result;
          session.referenceError = null;
          session.referenceLoading = false;
        }
        if (token !== patronRequestToken || !view.host.isConnected) return;
        log('Patron Reference', target, result.stats);
        await view.render(target, result);
      })
      .catch(error => {
        if (session && isReferenceSessionActive(session)) {
          session.referenceError = error;
          session.referenceLoading = false;
        }
        if (token !== patronRequestToken || !view.host.isConnected) return;
        warn('PATRON_REFERENCE_FAILED', error);
        view.setError(t('couldNotLoadBattleLogReference'), target);
      });
  }

  async function launchTraining(slotNumber, mode, snapshotHint = null) {
    if (hasOpenDemoBattle()) fail('DEMO_BATTLE_ALREADY_OPEN');

    // Prefer the already-rendered live context. A fresh PopupManager lookup is unnecessary
    // and can briefly fail while Hero Wars mutates popup state.
    const snapshot = snapshotHint?.kind ? snapshotHint : detectContextSnapshot();
    const data = getLaunchData(snapshot, slotNumber);
    // Capture only target metadata before opening Combat Training. Reference failures must
    // never block MAX itself; Patron data is loaded later from real GW/CoW battle logs only.
    let patronTarget = null;
    if (mode === MODES.MAX && data.isHero && (snapshot.kind === 'GW' || snapshot.kind === 'CoW')) {
      try { patronTarget = getPatronTarget(snapshot, data.item); }
      catch (error) { warn('PATRON_TARGET_SKIPPED', error); }
    }
    const Presets = findClass(CLASS.demoPresets);
    const Demo = findClass(CLASS.demoMediator);

    const currentPresets = new Presets(
      data.buffs,
      data.banner,
      data.pet,
      data.team.slice()
    );

    let demo;
    let demoOpened = false;
    let viewBanner = data.banner;

    if (mode === MODES.MAX) {
      // CoW keeps the proven helper-side MAX banner clone.
      // GW uses Hero Wars' native MAX conversion only; do not independently
      // replace the enemy's Pattern items with Absolute MAX Patterns here.
      viewBanner = snapshot.kind === 'CoW' ? buildMaxBanner(data.banner) : null;

      if (snapshot.kind === 'CoW') {
        // CoW native MAX path: a non-zero slot/fortification entry id triggers the game's MAX conversion.
        demo = new Demo(
          snapshot.mediator.player,
          data.isHero,
          data.battleMode,
          0,
          data.descId,
          currentPresets
        );
        const prepared = await waitForCoWMaxDisplay(demo, data.team.length);
        if (viewBanner) setBattleTeamBanner(prepared.team, viewBanner);
      } else {
        // GW has no native Combat Training button. In the current Hero Wars build,
        // applying the MAX preset before open() is overwritten by popup initialization.
        // Open the native Create Hero battle popup first, then apply MAX to its live model.
        demo = new Demo(
          snapshot.mediator.player,
          data.isHero,
          data.battleMode,
          0,
          data.descId,
          currentPresets
        );
        log('GW launch base preset', {
          slotNumber,
          descId: data.descId,
          teamCount: data.team.length,
          hasPet: Boolean(data.pet),
          hasBanner: Boolean(data.banner),
        });

        demo.open();
        demoOpened = true;
        await waitFor(() => hasOpenDemoBattle(), { code: 'GW_DEMO_OPEN_TIMEOUT' });

        const prepared = await forceGwMaxDisplay(demo, data.team.length);
        // Keep the banner produced by the game's native MAX preset path.
        if (!demo[prepared.control.getterName]()) fail('GW_MAX_NOT_ACTIVE');

        log('GW post-open MAX ready', {
          slotNumber,
          teamCount: data.team.length,
          defenderMax: Boolean(demo[prepared.control.getterName]()),
        });
      }
    } else if (mode === MODES.MAX_CURR) {
      viewBanner = buildMaxBanner(data.banner);

      if (snapshot.kind === 'CoW') {
        // Build a normal CoW MAX visual team first for MAX(CURR).
        demo = new Demo(
          snapshot.mediator.player,
          data.isHero,
          data.battleMode,
          0,
          data.descId,
          currentPresets
        );
        const prepared = await waitForCoWMaxDisplay(demo, data.team.length);
        if (viewBanner) setBattleTeamBanner(prepared.team, viewBanner);
        // Then change only the internal battle condition back to CURRENT. The UI selection stays MAX.
        setInternalCurrentWithoutChangingView(demo, prepared.control);
      } else {
        // GW MAX(CURR): create the MAX view using the game MAX conversion, then retain that view while returning the internal flag to CURRENT.
        demo = new Demo(
          snapshot.mediator.player,
          data.isHero,
          data.battleMode,
          0,
          data.descId,
          currentPresets
        );
        const prepared = await forceGwMaxDisplay(demo, data.team.length);
        if (viewBanner) setBattleTeamBanner(prepared.team, viewBanner);
        setInternalCurrentWithoutChangingView(demo, prepared.control);
      }

      setDefenderUser(demo, data.targetUser);
      const defenseControl = resolvePowerControl(demo, 'get_defenderMaxPowerMode');
      const attackControl = resolvePowerControl(demo, 'get_attackerMaxPowerMode');
      if (demo[defenseControl.getterName]()) fail('MAX_CURR_INTERNAL_DEFENSE_NOT_CURRENT');
      if (demo[attackControl.getterName]()) fail('MAX_CURR_INTERNAL_ATTACK_NOT_CURRENT');
    } else {
      fail('INVALID_MODE', String(mode));
    }

    if (!demoOpened) demo.open();

    // GW formal UX: stop on Create Hero battle after the MAX defense is ready.
    // The user opens the native Defense editor manually with the pencil button
    // only when adjustments (for example Patron selection) are needed.
    const defenseEditorOpened = false;

    return {
      kind: snapshot.kind,
      slotNumber,
      mode,
      teamCount: data.team.length,
      userName: data.item.userName,
      userId: data.item.userId,
      building: data.item.building || patronTarget?.building || '',
      teamKey: data.item.teamKey || getSlotTeamKey(data.team),
      defenseEditorOpened,
      patronTarget,
      demoMediator: demo,
      battleLaunchBaseline: captureBattleLaunchBaseline(demo),
    };
  }

  function humanizeError(error) {
    const code = error?.code;
    switch (code) {
      case 'HAXE_NOT_READY':
        return t('errorWaitGameLoad');
      case 'ATTACK_CONTEXT_NOT_FOUND':
      case 'SLOT_LIST_NOT_FOUND':
        return t('openGwAttackTarget');
      case 'DEMO_BATTLE_ALREADY_OPEN':
        return t('errorCloseCombatTraining');
      case 'SLOT_NOT_FOUND':
        return t('errorSlotNotCurrentBuilding');
      case 'SLOT_NOT_READY':
        return t('errorSlotNotAvailable');
      case 'SLOT_EMPTY':
        return t('errorSlotNoTeam');
      case 'TARGET_USER_NOT_FOUND':
        return t('errorTargetUser');
      case 'MAX_PATTERN_MATCH_FAILED':
      case 'ABSOLUTE_PATTERN_LIST_NOT_FOUND':
        return t('errorMaxPattern');
      case 'GW_MAX_TEAM_TIMEOUT':
      case 'GW_MAX_TEAM_FIRST_APPLY_TIMEOUT':
      case 'GW_MAX_TEAM_SECOND_APPLY_TIMEOUT':
      case 'GW_MAX_TEAM_LATE_RESTORE_TIMEOUT':
      case 'COW_MAX_TEAM_TIMEOUT':
        return t('errorMaxTeamTimeout');
      default:
        return t('errorStopped', { detail: code ?? error?.message ?? 'unknown' });
    }
  }

  async function copyText(text) {
    if (!text) return false;
    try {
      if (navigator.clipboard?.writeText) {
        await navigator.clipboard.writeText(String(text));
        return true;
      }
    } catch {}

    try {
      const textarea = document.createElement('textarea');
      textarea.value = String(text);
      textarea.style.position = 'fixed';
      textarea.style.opacity = '0';
      textarea.style.pointerEvents = 'none';
      document.body.appendChild(textarea);
      textarea.focus();
      textarea.select();
      const ok = document.execCommand('copy');
      textarea.remove();
      return ok;
    } catch {
      return false;
    }
  }

  function loadUiState() {
    try {
      const raw = JSON.parse(localStorage.getItem(UI_STORAGE_KEY) || '{}');
      return {
        // Brand-new installs start expanded; only an explicitly saved user choice starts minimized.
        minimized: Object.prototype.hasOwnProperty.call(raw, 'minimized') ? Boolean(raw.minimized) : false,
        expandedX: Number.isFinite(raw.expandedX) ? clamp(raw.expandedX, 0, 1) : 0.96,
        expandedY: Number.isFinite(raw.expandedY) ? clamp(raw.expandedY, 0, 1) : 0.48,
        miniSide: raw.miniSide === 'left' ? 'left' : 'right',
        miniY: Number.isFinite(raw.miniY) ? clamp(raw.miniY, 0, 1) : 0.25,
        fontSize: Number.isFinite(raw.fontSize) ? clamp(Math.round(raw.fontSize), UI_FONT_MIN, UI_FONT_MAX) : UI_FONT_DEFAULT,
        mainPanelWidth: Number.isFinite(raw.mainPanelWidth) ? Math.max(MAIN_PANEL_MIN_WIDTH, Math.round(raw.mainPanelWidth)) : MAIN_PANEL_DEFAULT_WIDTH,
        mainPanelHeight: Number.isFinite(raw.mainPanelHeight) ? Math.max(PANEL_MIN_HEIGHT, Math.round(raw.mainPanelHeight)) : null,
        refPanelWidth: Number.isFinite(raw.refPanelWidth) ? Math.max(REF_PANEL_MIN_WIDTH, Math.round(raw.refPanelWidth)) : REF_PANEL_DEFAULT_WIDTH,
        refPanelHeight: Number.isFinite(raw.refPanelHeight) ? Math.max(PANEL_MIN_HEIGHT, Math.round(raw.refPanelHeight)) : null,
        refPanelX: Number.isFinite(raw.refPanelX) ? clamp(raw.refPanelX, 0, 1) : null,
        refPanelY: Number.isFinite(raw.refPanelY) ? clamp(raw.refPanelY, 0, 1) : null,
        refMiniSide: raw.refMiniSide === 'left' ? 'left' : raw.refMiniSide === 'right' ? 'right' : null,
        refMiniY: Number.isFinite(raw.refMiniY) ? clamp(raw.refMiniY, 0, 1) : null,
      };
    } catch {
      return {
        minimized: false, expandedX: 0.96, expandedY: 0.48, miniSide: 'right', miniY: 0.25, fontSize: UI_FONT_DEFAULT,
        mainPanelWidth: MAIN_PANEL_DEFAULT_WIDTH, mainPanelHeight: null, refPanelWidth: REF_PANEL_DEFAULT_WIDTH, refPanelHeight: null,
        refPanelX: null, refPanelY: null, refMiniSide: null, refMiniY: null,
      };
    }
  }

  function saveUiState(state) {
    try {
      localStorage.setItem(UI_STORAGE_KEY, JSON.stringify(state));
    } catch {}
  }

  function createPanel() {
    if (document.getElementById(HOST_ID)) return null;

    const uiState = loadUiState();
    const host = document.createElement('div');
    host.id = HOST_ID;
    host.style.position = 'fixed';
    host.style.left = '0px';
    host.style.top = '0px';
    host.style.zIndex = '2147483647';
    host.style.pointerEvents = 'auto';
    host.style.userSelect = 'none';
    host.style.display = 'none';

    const shadow = host.attachShadow({ mode: 'open' });
    shadow.innerHTML = `
      <style>
        :host { all: initial; }
        * { box-sizing: border-box; }
        .panel {
          position: relative;
          width: 330px;
          border: 1px solid rgba(255,255,255,.20);
          border-radius: 9px;
          background: rgba(24,27,33,.95);
          color: #f6f6f6;
          font: var(--hwct-font-size, 13px)/1.35 Arial, sans-serif;
          box-shadow: 0 5px 20px rgba(0,0,0,.42);
          overflow: visible;
          display:flex;
          flex-direction:column;
        }
        .header {
          display: flex;
          align-items: center;
          min-height: 34px;
          padding: 6px 7px 6px 10px;
          background: rgba(255,255,255,.055);
          cursor: grab;
          touch-action: none;
        }
        .header.dragging { cursor: grabbing; }
        .title { font-weight:700; flex:1; min-width:0; font-size:1.04em; display:flex; align-items:center; }
        .tool-mark { flex:0 0 auto; margin-right:4px; color:#e3b65f; font-size:1.55em; line-height:.8; text-shadow:0 0 4px rgba(227,182,95,.28); }
        .version { color:#c9c9c9; font-weight:400; }
        .context { margin-left:7px; color:#c9c9c9; font-size:.92em; font-weight:400; }
        .header-actions { position:relative; display:flex; gap:3px; align-items:center; }
        .gear { width:25px; height:22px; padding:0; border:0; border-radius:4px; background:rgba(255,255,255,.09); color:#eee; cursor:pointer; font:700 13px Arial; }
        .settings { position:absolute; right:28px; top:26px; z-index:3; min-width:166px; padding:9px; border:1px solid rgba(255,255,255,.2); border-radius:7px; background:rgba(29,32,39,.99); box-shadow:0 6px 18px rgba(0,0,0,.5); cursor:default; }
        .settings-title { font-weight:700; margin-bottom:7px; }
        .font-controls { display:grid; grid-template-columns:30px 1fr 30px; gap:6px; align-items:center; }
        .font-controls button, .settings-save, .settings-reset { border:1px solid rgba(255,255,255,.18); border-radius:5px; background:rgba(255,255,255,.07); color:#eee; cursor:pointer; padding:4px 6px; }
        .font-value { text-align:center; color:#ddd; }
        .settings-save, .settings-reset { width:100%; margin-top:7px; }
        .settings-save { background:rgba(221,177,94,.18); border-color:rgba(240,199,120,.5); }
        .minimize {
          width: 25px; height: 22px; padding: 0; border: 0; border-radius: 4px;
          background: rgba(255,255,255,.09); color: #eee; cursor: pointer; font: 700 15px Arial;
        }
        .body { padding: 9px; min-height:0; overflow:auto; }
        .slots { display: flex; flex-direction: column; gap: 4px; }
        .slot-row {
          display: grid;
          grid-template-columns: 34px minmax(0, 1fr);
          align-items: center;
          min-height: 31px;
          gap: 7px;
          padding: 3px 5px;
          border-radius: 5px;
          background: rgba(255,255,255,.035);
        }
        .slot-row.inactive { opacity: .48; }
        .slot-btn {
          position: relative;
          width: 32px; height: 25px; padding: 0; border: 0; border-radius: 5px;
          background: #ddb15e; color: #21180a; cursor: pointer; font:700 .95em Arial;
        }
        .slot-btn:disabled { background: #777; color: #ddd; cursor: default; }
        .slot-btn.my-target::after {
          content: ''; position: absolute; right: -4px; top: -4px; width: 9px; height: 9px;
          border-radius: 50%; background: #f22626; border: 1px solid #ffe1c9;
          box-shadow: 0 0 0 1px rgba(0,0,0,.45), 0 0 5px rgba(242,38,38,.8);
          pointer-events: none;
        }
        .player-name {
          min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
          color: #f1f1f1;
        }
        .player-name.launchable { cursor:pointer; }
        .state-hint { color: #a8a8a8; }
        .empty-message { padding: 8px 4px; color: #bbb; text-align: center; }
        .status {
          min-height: 18px; margin-top: 8px; padding-top: 6px;
          border-top: 1px solid rgba(255,255,255,.09); color: #cfcfcf; font-size:.90em;
          word-break:break-word;
        }
        .status.error { color: #ffb3b3; }
        .status.ok { color: #bde8bd; }
        .mini {
          width:44px; height:44px; border:1px solid rgba(255,255,255,.22); border-radius:9px;
          background:rgba(24,27,33,.96); color:#f1f1f1; cursor:grab; touch-action:none; padding:3px;
          box-shadow:0 4px 14px rgba(0,0,0,.35); display:flex; flex-direction:column; align-items:center; justify-content:center; gap:0;
        }
        .mini.dragging { cursor:grabbing; }
        .mini-icon { font:700 27px/27px Arial; color:#e3b65f; text-shadow:0 0 4px rgba(227,182,95,.28); }
        .modal-backdrop {
          position: fixed; inset: 0; z-index: 2147483647; display:flex; align-items:center; justify-content:center;
          background: rgba(0,0,0,.38); pointer-events:auto;
        }
        .modal-box {
          width:min(390px, calc(100vw - 36px)); padding:14px 15px 12px; border:1px solid rgba(255,255,255,.25);
          border-radius:9px; background:rgba(24,27,33,.985); color:#f6f6f6; box-shadow:0 8px 28px rgba(0,0,0,.58);
          font:12px/1.5 Arial,sans-serif; user-select:text;
        }
        .modal-title { font-weight:700; font-size:14px; margin-bottom:7px; }
        .modal-message { white-space:pre-line; color:#ededed; }
        .modal-actions { display:flex; justify-content:flex-end; margin-top:12px; }
        .modal-ok { min-width:72px; padding:6px 13px; border:1px solid #f0c778; border-radius:5px; background:#ddb15e;
          color:#21180a; cursor:pointer; font:700 12px Arial; }
        .resize-handle { position:absolute; z-index:20; touch-action:none; }
        .resize-n, .resize-s { left:12px; right:12px; height:7px; }
        .resize-n { top:-3px; cursor:ns-resize; } .resize-s { bottom:-3px; cursor:ns-resize; }
        .resize-e, .resize-w { top:12px; bottom:12px; width:7px; }
        .resize-e { right:-3px; cursor:ew-resize; } .resize-w { left:-3px; cursor:ew-resize; }
        .resize-ne, .resize-nw, .resize-se, .resize-sw { width:13px; height:13px; }
        .resize-ne { right:-4px; top:-4px; cursor:nesw-resize; } .resize-nw { left:-4px; top:-4px; cursor:nwse-resize; }
        .resize-se { right:-4px; bottom:-4px; cursor:nwse-resize; } .resize-sw { left:-4px; bottom:-4px; cursor:nesw-resize; }
        [hidden] { display: none !important; }
      </style>
      <div id="panel" class="panel">
        <div id="header" class="header">
          <div class="title"><span class="tool-mark">⚔</span>${t('combatTraining')} <span class="version">· v${VERSION}</span><span id="context" class="context"></span></div>
          <div class="header-actions">
            <button id="settings-button" class="gear" type="button" title="${t('settings')}">⚙</button>
            <button id="minimize" class="minimize" type="button" title="${t('minimize')}">−</button>
            <div id="settings-popover" class="settings" hidden>
              <div class="settings-title">${t('fontSize')}</div>
              <div class="font-controls"><button id="font-minus" type="button">−</button><div id="font-value" class="font-value"></div><button id="font-plus" type="button">+</button></div>
              <button id="font-save" class="settings-save" type="button">${t('save')}</button>
              <button id="font-reset" class="settings-reset" type="button">${t('reset')}</button>
            </div>
          </div>
        </div>
        <div class="body">
          <div id="slots" class="slots"></div>
          <div id="status" class="status">${t('openGwAttackTarget')}</div>
        </div>
      </div>
      <div id="warning-modal" class="modal-backdrop" hidden>
        <div class="modal-box" role="dialog" aria-modal="true" aria-labelledby="warning-title">
          <div id="warning-title" class="modal-title">${t('defenseEditorOpen')}</div>
          <div id="warning-message" class="modal-message"></div>
          <div class="modal-actions"><button id="warning-ok" class="modal-ok" type="button">${t('ok')}</button></div>
        </div>
      </div>
      <button id="mini" class="mini" type="button" hidden aria-label="${t('openCombatTraining')}" title="${t('combatTraining')} v${VERSION}"><span class="mini-icon">⚔</span></button>
    `;

    document.documentElement.appendChild(host);

    const panel = shadow.getElementById('panel');
    const header = shadow.getElementById('header');
    const minimizeButton = shadow.getElementById('minimize');
    const miniButton = shadow.getElementById('mini');
    const settingsButton = shadow.getElementById('settings-button');
    const settingsPopover = shadow.getElementById('settings-popover');
    const fontMinus = shadow.getElementById('font-minus');
    const fontPlus = shadow.getElementById('font-plus');
    const fontSave = shadow.getElementById('font-save');
    const fontReset = shadow.getElementById('font-reset');
    const fontValue = shadow.getElementById('font-value');
    const contextLabel = shadow.getElementById('context');
    const slotsContainer = shadow.getElementById('slots');
    const status = shadow.getElementById('status');
    const warningModal = shadow.getElementById('warning-modal');
    const warningTitle = shadow.getElementById('warning-title');
    const warningMessage = shadow.getElementById('warning-message');
    const warningOk = shadow.getElementById('warning-ok');

    panel.style.width = `${getResponsivePanelWidth(uiState.mainPanelWidth, MAIN_PANEL_DEFAULT_WIDTH, MAIN_PANEL_MIN_WIDTH)}px`;
    if (Number.isFinite(uiState.mainPanelHeight)) {
      panel.style.height = `${Math.min(uiState.mainPanelHeight, Math.max(PANEL_MIN_HEIGHT, window.innerHeight))}px`;
    }

    let scopeVisible = false;
    let hiddenForReference = false;

    function updateHostVisibility() {
      const showMain = scopeVisible && !hiddenForReference;
      host.style.display = showMain ? '' : 'none';
      if (patronReferenceView?.getKind?.() === 'GW') patronReferenceView.setScopeVisible(scopeVisible && hiddenForReference);
      if (showMain) requestAnimationFrame(applyPosition);
    }

    function applyFontSize(size) {
      uiState.fontSize = clamp(Math.round(Number(size) || UI_FONT_DEFAULT), UI_FONT_MIN, UI_FONT_MAX);
      host.style.setProperty('--hwct-font-size', `${uiState.fontSize}px`);
      fontValue.textContent = `${uiState.fontSize}px`;
      patronReferenceView?.setFontSize?.(uiState.fontSize);
      saveUiState(uiState);
      if (host.style.display !== 'none') requestAnimationFrame(applyPosition);
    }
    applyFontSize(uiState.fontSize);

    settingsButton.addEventListener('click', event => {
      event.stopPropagation();
      settingsPopover.hidden = !settingsPopover.hidden;
    });
    fontMinus.addEventListener('click', () => applyFontSize(uiState.fontSize - 1));
    fontPlus.addEventListener('click', () => applyFontSize(uiState.fontSize + 1));
    fontSave.addEventListener('click', () => { settingsPopover.hidden = true; });
    fontReset.addEventListener('click', () => applyFontSize(UI_FONT_DEFAULT));

    function setStatus(text, type = '') {
      status.textContent = text;
      status.className = `status${type ? ` ${type}` : ''}`;
    }


    function showWarningModal(message, title = t('defenseEditorOpen')) {
      warningTitle.textContent = title;
      warningMessage.textContent = message;
      warningModal.hidden = false;
      window.setTimeout(() => warningOk.focus(), 0);
    }

    function closeWarningModal() {
      warningModal.hidden = true;
    }

    warningOk.addEventListener('click', closeWarningModal);

    function viewportSizeFor(element) {
      const rect = element.getBoundingClientRect();
      return {
        width: rect.width || (element === panel ? 330 : 44),
        height: rect.height || (element === panel ? 200 : 44),
        maxX: Math.max(0, window.innerWidth - (rect.width || (element === panel ? 330 : 44))),
        maxY: Math.max(0, window.innerHeight - (rect.height || (element === panel ? 200 : 44))),
      };
    }

    function placeExpanded() {
      panel.hidden = false;
      miniButton.hidden = true;
      const size = viewportSizeFor(panel);
      host.style.left = `${Math.round(size.maxX * uiState.expandedX)}px`;
      host.style.top = `${Math.round(size.maxY * uiState.expandedY)}px`;
    }

    function placeMinimized() {
      panel.hidden = true;
      miniButton.hidden = false;
      const size = viewportSizeFor(miniButton);
      const y = Math.round(size.maxY * uiState.miniY);
      host.style.top = `${y}px`;
      host.style.left = uiState.miniSide === 'right'
        ? `${Math.max(0, window.innerWidth - size.width)}px`
        : '0px';
    }

    function applyPosition() {
      if (uiState.minimized) placeMinimized();
      else placeExpanded();
    }

    function saveExpandedPosition() {
      const rect = panel.getBoundingClientRect();
      const maxX = Math.max(1, window.innerWidth - rect.width);
      const maxY = Math.max(1, window.innerHeight - rect.height);
      uiState.expandedX = clamp(rect.left / maxX, 0, 1);
      uiState.expandedY = clamp(rect.top / maxY, 0, 1);
      saveUiState(uiState);
    }


    const removeMainResizeHandles = installEightWayResize({
      host, panel, shadow, minWidth: MAIN_PANEL_MIN_WIDTH, minHeight: PANEL_MIN_HEIGHT,
      onResizeEnd: rect => {
        uiState.mainPanelWidth = Math.round(rect.width);
        uiState.mainPanelHeight = Math.round(rect.height);
        saveExpandedPosition();
      },
    });

    function setExpandedPositionPixels(left, top) {
      uiState.minimized = false;
      panel.hidden = false;
      miniButton.hidden = true;
      const width = panel.getBoundingClientRect().width || 330;
      const height = panel.getBoundingClientRect().height || 200;
      const safeLeft = clamp(Number(left) || 0, 0, Math.max(0, window.innerWidth - width));
      const safeTop = clamp(Number(top) || 0, 0, Math.max(0, window.innerHeight - height));
      host.style.left = `${Math.round(safeLeft)}px`;
      host.style.top = `${Math.round(safeTop)}px`;
      const maxX = Math.max(1, window.innerWidth - width);
      const maxY = Math.max(1, window.innerHeight - height);
      uiState.expandedX = clamp(safeLeft / maxX, 0, 1);
      uiState.expandedY = clamp(safeTop / maxY, 0, 1);
      saveUiState(uiState);
    }

    function minimize() {
      saveExpandedPosition();
      const rect = panel.getBoundingClientRect();
      uiState.minimized = true;
      uiState.miniSide = rect.left + rect.width / 2 < window.innerWidth / 2 ? 'left' : 'right';
      const miniHeight = 44;
      uiState.miniY = clamp(rect.top / Math.max(1, window.innerHeight - miniHeight), 0, 1);
      saveUiState(uiState);
      placeMinimized();
    }

    function restore() {
      uiState.minimized = false;
      saveUiState(uiState);
      placeExpanded();
    }

    minimizeButton.addEventListener('click', event => {
      event.stopPropagation();
      minimize();
    });
    let miniDrag = null;
    miniButton.addEventListener('pointerdown', event => {
      if (event.button !== 0) return;
      const rect = miniButton.getBoundingClientRect();
      miniDrag = {
        id: event.pointerId,
        dx: event.clientX - rect.left,
        dy: event.clientY - rect.top,
        startX: event.clientX,
        startY: event.clientY,
        moved: false,
      };
      miniButton.classList.add('dragging');
      miniButton.setPointerCapture?.(event.pointerId);
      event.preventDefault();
    });

    miniButton.addEventListener('pointermove', event => {
      if (!miniDrag || event.pointerId !== miniDrag.id) return;
      if (Math.hypot(event.clientX - miniDrag.startX, event.clientY - miniDrag.startY) >= 4) miniDrag.moved = true;
      if (!miniDrag.moved) return;
      const rect = miniButton.getBoundingClientRect();
      const left = clamp(event.clientX - miniDrag.dx, 0, Math.max(0, window.innerWidth - rect.width));
      const top = clamp(event.clientY - miniDrag.dy, 0, Math.max(0, window.innerHeight - rect.height));
      host.style.left = `${Math.round(left)}px`;
      host.style.top = `${Math.round(top)}px`;
    });

    function endMiniDrag(event) {
      if (!miniDrag || event.pointerId !== miniDrag.id) return;
      const moved = miniDrag.moved;
      miniDrag = null;
      miniButton.classList.remove('dragging');
      if (!moved) {
        restore();
        return;
      }
      const rect = miniButton.getBoundingClientRect();
      uiState.miniSide = rect.left + rect.width / 2 < window.innerWidth / 2 ? 'left' : 'right';
      uiState.miniY = clamp(rect.top / Math.max(1, window.innerHeight - rect.height), 0, 1);
      saveUiState(uiState);
      placeMinimized();
    }
    miniButton.addEventListener('pointerup', endMiniDrag);
    miniButton.addEventListener('pointercancel', endMiniDrag);

    let drag = null;
    header.addEventListener('pointerdown', event => {
      if (event.target.closest?.('.header-actions, .settings') || event.button !== 0) return;
      const rect = panel.getBoundingClientRect();
      drag = {
        id: event.pointerId,
        dx: event.clientX - rect.left,
        dy: event.clientY - rect.top,
      };
      header.classList.add('dragging');
      header.setPointerCapture?.(event.pointerId);
      event.preventDefault();
    });

    header.addEventListener('pointermove', event => {
      if (!drag || event.pointerId !== drag.id) return;
      const rect = panel.getBoundingClientRect();
      const left = clamp(event.clientX - drag.dx, 0, Math.max(0, window.innerWidth - rect.width));
      const top = clamp(event.clientY - drag.dy, 0, Math.max(0, window.innerHeight - rect.height));
      host.style.left = `${Math.round(left)}px`;
      host.style.top = `${Math.round(top)}px`;
    });

    function endDrag(event) {
      if (!drag || event.pointerId !== drag.id) return;
      drag = null;
      header.classList.remove('dragging');
      saveExpandedPosition();
    }
    header.addEventListener('pointerup', endDrag);
    header.addEventListener('pointercancel', endDrag);

    window.addEventListener('resize', () => {
      if (host.style.display === 'none') return;
      window.requestAnimationFrame(() => {
        const responsiveWidth = getResponsivePanelWidth(uiState.mainPanelWidth, MAIN_PANEL_DEFAULT_WIDTH, MAIN_PANEL_MIN_WIDTH);
        panel.style.width = `${Math.round(responsiveWidth)}px`;
        const rect = panel.getBoundingClientRect();
        if (rect.height > window.innerHeight) panel.style.height = `${Math.max(1, window.innerHeight)}px`;
        applyPosition();
      });
    });

    function stateText(state) {
      switch (state) {
        case 'ready': return '';
        case 'empty': return t('stateEmpty');
        case 'defeated': return t('stateDefeated');
        case 'inBattle': return t('stateInBattle');
        default: return state || t('stateUnknown');
      }
    }

    function makeDefenseKey(snapshot, slotInfo) {
      return [
        snapshot?.kind || '',
        slotInfo?.building || '',
        Number(slotInfo?.slotNumber ?? 0),
        slotInfo?.userId || '',
        slotInfo?.teamKey || '',
      ].join('|');
    }

    function clearClosedNativeCowSession() {
      if (!nativeCowSession) return false;

      const demoOpen = hasOpenDemoBattle();
      const defenseEditorOpen = hasOpenDefenseEditor();
      const attackEditorOpen = hasOpenAttackEditor();

      if (attackEditorOpen) nativeCowSession.attackEditorSeen = true;

      const launchDetected = (
        nativeCowSession.attackEditorSeen &&
        hasBattleTeamCommitTransition(nativeCowSession)
      );
      const battleScreenDetected = hasOpenBattlePreloader() || hasOpenBattleView();

      if (launchDetected || battleScreenDetected) {
        nativeCowSession = null;
        hidePatronReference({ restoreMain: false });
        return true;
      }

      if (demoOpen || defenseEditorOpen || attackEditorOpen) return false;

      nativeCowSession = null;
      hidePatronReference({ restoreMain: false });
      return true;
    }

    function clearClosedActiveSession() {
      if (!activeDefenseSession) return false;

      const demoOpen = hasOpenDemoBattle();
      const defenseEditorOpen = hasOpenDefenseEditor();
      const attackEditorOpen = hasOpenAttackEditor();

      // Remember that the user reached the Assemble Attack Team screen. If that
      // screen and the Demo Battle creator both disappear, To battle! has ended
      // Patron Reference's job. The main helper is restored internally; during
      // the battle itself normal war-scope visibility rules may keep it hidden.
      if (attackEditorOpen) activeDefenseSession.attackEditorSeen = true;

      const launchDetected = (
        activeDefenseSession.attackEditorSeen &&
        hasBattleTeamCommitTransition(activeDefenseSession)
      );
      const battleScreenDetected = hasOpenBattlePreloader() || hasOpenBattleView();

      if (launchDetected || battleScreenDetected) {
        activeDefenseSession = null;
        hidePatronReference({ restoreMain: false });
        hiddenForReference = true;
        updateHostVisibility();
        setStatus('');
        return true;
      }

      // Conservative fallback: if the whole Demo Battle editor stack disappears,
      // the Reference is no longer relevant.
      if (
        activeDefenseSession.attackEditorSeen &&
        !attackEditorOpen &&
        !demoOpen &&
        !defenseEditorOpen
      ) {
        activeDefenseSession = null;
        hidePatronReference({ restoreMain: false });
        hiddenForReference = true;
        updateHostVisibility();
        setStatus('');
        return true;
      }

      // Existing close behavior: once the whole Combat Training editor stack is
      // closed (for example X → X back to the attack list), restore the main panel.
      if (!demoOpen && !defenseEditorOpen && !attackEditorOpen) {
        activeDefenseSession = null;
        hidePatronReference({ restoreMain: true });
        setStatus('');
        return true;
      }

      // X from Assemble Attack Team back to Create Hero battle is not a completed
      // training launch, so allow a later attack-editor visit to be observed again.
      if (activeDefenseSession.attackEditorSeen && !attackEditorOpen && demoOpen) {
        activeDefenseSession.attackEditorSeen = false;
      }

      return false;
    }

    async function handleLaunch(slotInfo, button) {
      if (launchBusy) return;

      clearClosedActiveSession();
      const requestedKey = makeDefenseKey(latestSnapshot, slotInfo);

      if (activeDefenseSession && hasOpenTrainingUi()) {
        if (activeDefenseSession.key === requestedKey && activeDefenseSession.patronTarget) {
          setStatus(t('referenceRestoredFor', { defense: formatDefenseLabel(activeDefenseSession) }), 'ok');
          showActivePatronReference();
          return;
        }

        setStatus(t('closeCurrentDefenseEditorFirst'), 'error');
        showWarningModal(t('closeCurrentDefenseEditorBeforeSelecting'));
        return;
      }

      if (hasOpenTrainingUi()) {
        setStatus(t('closeCurrentDefenseEditorFirst'), 'error');
        showWarningModal(t('closeCurrentDefenseEditorBeforeSelecting'));
        return;
      }

      launchBusy = true;
      [...slotsContainer.querySelectorAll('.slot-btn')].forEach(btn => { btn.disabled = true; });
      setStatus(t('preparingDefense', { slot: slotInfo.slotNumber }));
      try {
        const result = await launchTraining(slotInfo.slotNumber, MODES.MAX, latestSnapshot);
        setStatus(t('openedDefenseMax', { slot: result.slotNumber }), 'ok');

        activeDefenseSession = {
          key: [result.kind || '', result.building || '', Number(result.slotNumber), result.userId || '', result.teamKey || ''].join('|'),
          kind: result.kind,
          building: result.building || slotInfo.building || '',
          slotNumber: result.slotNumber,
          playerName: result.userName || slotInfo.userName || '',
          playerId: result.userId || slotInfo.userId || '',
          teamKey: result.teamKey || slotInfo.teamKey || '',
          patronTarget: result.patronTarget || null,
          referenceResult: null,
          referenceError: null,
          referenceLoading: false,
          attackEditorSeen: false,
          demoMediator: result.demoMediator || null,
          battleLaunchBaseline: result.battleLaunchBaseline || null,
        };

        if (result.patronTarget) startPatronReferenceLoad(result.patronTarget, activeDefenseSession);
        else hidePatronReference({ restoreMain: true });
      } catch (error) {
        warn(error);
        setStatus(humanizeError(error), 'error');
      } finally {
        launchBusy = false;
        renderSnapshot(latestSnapshot, true);
      }
    }

    function renderSnapshot(snapshot, force = false) {
      if (!snapshot) return;
      const signature = getSnapshotSignature(snapshot);
      if (!force && signature === latestSnapshotSignature) return;
      latestSnapshotSignature = signature;
      latestSnapshot = snapshot;

      contextLabel.textContent = '';
      slotsContainer.textContent = '';

      if (!snapshot.kind) {
        const message = document.createElement('div');
        message.className = 'empty-message';
        message.textContent = snapshot.status === 'loading'
          ? t('loadingGame')
          : t('openGwAttackTarget');
        slotsContainer.appendChild(message);
        return;
      }

      for (const slotInfo of snapshot.slots) {
        const row = document.createElement('div');
        row.className = `slot-row${slotInfo.canLaunch ? '' : ' inactive'}`;

        const slotButton = document.createElement('button');
        slotButton.type = 'button';
        slotButton.className = `slot-btn${slotInfo.isMyTarget ? ' my-target' : ''}`;
        slotButton.textContent = String(slotInfo.slotNumber);
        slotButton.disabled = launchBusy || !slotInfo.canLaunch;
        const assignmentHint = slotInfo.isMyTarget ? ` · ${t('assignedToYou')}` : '';
        slotButton.title = slotInfo.canLaunch
          ? `${t('openDefense', { slot: slotInfo.slotNumber })}${assignmentHint}`
          : `${stateText(slotInfo.state)}${assignmentHint}`;
        slotButton.addEventListener('click', () => handleLaunch(slotInfo, slotButton));

        const name = document.createElement('div');
        name.className = `player-name${slotInfo.canLaunch ? ' launchable' : ''}`;
        const baseName = slotInfo.userName || '—';
        const suffix = stateText(slotInfo.state);
        name.textContent = suffix ? `${baseName} · ${suffix}` : baseName;
        name.title = name.textContent;
        if (slotInfo.canLaunch) {
          name.addEventListener('click', () => handleLaunch(slotInfo, slotButton));
        }

        row.append(slotButton, name);
        slotsContainer.appendChild(row);
      }
    }

    async function refresh() {
      try {
        clearClosedNativeCowSession();
        clearClosedActiveSession();
        const warScope = isWarScopeActive();
        const snapshot = warScope ? detectContextSnapshot() : { kind: null, status: 'noAttackPopup', slots: [] };
        latestSnapshot = snapshot;
        scopeVisible = snapshot?.kind === 'GW';

        if (
          scopeVisible &&
          !activeDefenseSession &&
          !getCurrentReferenceSession() &&
          !patronReferenceView &&
          !referenceReopenLauncher &&
          !hasOpenTrainingUi()
        ) {
          hiddenForReference = false;
        }

        updateHostVisibility();
        if (!scopeVisible) return;
        renderSnapshot(snapshot);
      } catch (error) {
        scopeVisible = false;
        updateHostVisibility();
        latestSnapshot = { kind: null, status: 'error', slots: [] };
        setStatus(humanizeError(error), 'error');
      }
    }

    selectedMode = MODES.MAX;

    window.requestAnimationFrame(applyPosition);
    refresh();
    const timer = window.setInterval(refresh, REFRESH_MS);

    return {
      host,
      shadow,
      refresh,
      setStatus,
      getPosition() {
        const rect = panel.getBoundingClientRect();
        return { left: rect.left, top: rect.top };
      },
      setPosition(left, top) {
        setExpandedPositionPixels(left, top);
      },
      hideForReference() {
        hiddenForReference = true;
        updateHostVisibility();
      },
      showAfterReference() {
        hiddenForReference = false;
        uiState.minimized = false;
        updateHostVisibility();
        if (scopeVisible) requestAnimationFrame(placeExpanded);
      },
      setScopeVisible(visible) {
        scopeVisible = Boolean(visible);
        updateHostVisibility();
      },
      getFontSize() { return uiState.fontSize; },
      setFontSize(size) { applyFontSize(size); },
      destroy() {
        window.clearInterval(timer);
        removeMainResizeHandles?.();
        host.remove();
        hidePatronReference({ restoreMain: false });
        hideReferenceReopenLauncher();
      },
    };
  }

  if (!document.getElementById(HOST_ID)) {
    const panel = createPanel();
    mainPanelController = panel;

    // Haxe classes can arrive after document-idle. Poll only until the two native
    // lifecycle hooks are installed; Defense Reference itself is event-driven.
    if (!installNativeCowHooksOnce()) {
      const hookTimer = window.setInterval(() => {
        if (installNativeCowHooksOnce()) window.clearInterval(hookTimer);
      }, 1000);
    }

    window.HWCombatTrainingHelper = Object.freeze({
      version: VERSION,
      refresh: () => panel?.refresh(),
      closeDefenseReference: () => hidePatronReference({ restoreMain: patronReferenceView?.getKind?.() === 'GW' }),
      setDefeatedStyle: style => {
        defeatedDisplayStyle = ['cross', 'label', 'gray'].includes(style) ? style : 'cross';
        patronReferenceView?.setDefeatedStyle?.(defeatedDisplayStyle);
        return defeatedDisplayStyle;
      },
      getDefeatedStyle: () => defeatedDisplayStyle,
    });
    log('loaded');
  }
})();