Adds Combat Training to Guild War in the game Hero Wars.
// ==UserScript==
// @name HeroWars CombatTraining Helper
// @namespace sora.tools
// @version 0.4.3
// @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.3';
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 UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
const UPDATE_CHECK_STORAGE_KEY = 'sora.hwct.update.v1';
const GREASY_FORK_SCRIPT_ID = 591341;
const GREASY_FORK_JSON_URL = `https://api.greasyfork.org/en/scripts/${GREASY_FORK_SCRIPT_ID}.json`;
const WAIT_STEP_MS = 80;
const WAIT_TIMEOUT_MS = 6000;
const ATTACK_EDITOR_RESTORE_GRACE_MS = WAIT_STEP_MS * 4;
const ATTACK_EDITOR_RETURN_GRACE_MS = REFRESH_MS * 3;
const UI_FONT_MIN = 8;
const UI_FONT_MAX = 20;
const UI_FONT_DEFAULT = 13;
const MAIN_PANEL_DEFAULT_WIDTH = 250;
const REF_PANEL_DEFAULT_WIDTH = 410;
const MAIN_PANEL_MIN_WIDTH = 180;
const REF_PANEL_TECHNICAL_MIN_WIDTH_FALLBACK = 96;
const PANEL_MIN_HEIGHT = 140;
const PANEL_VIEWPORT_WIDTH_GUTTER = 16;
// Initial Titan release scope: Current Defense only. Keep the historical
// implementation in-tree for later UI redesign, but do not query/render it yet.
const TITAN_PAST_REFERENCE_ENABLED = false;
function getPanelAvailableWidth() {
return Math.max(1, window.innerWidth - PANEL_VIEWPORT_WIDTH_GUTTER);
}
function getResponsivePanelWidth(preferredWidth, defaultWidth, normalMinWidth) {
const available = getPanelAvailableWidth();
const minWidth = typeof normalMinWidth === 'function' ? normalMinWidth() : normalMinWidth;
const safeMinWidth = Math.max(1, Number(minWidth) || 1);
const preferred = Number.isFinite(Number(preferredWidth)) ? Number(preferredWidth) : defaultWidth;
return Math.min(Math.max(safeMinWidth, preferred), available);
}
function getResponsivePanelMinWidth(normalMinWidth) {
const minWidth = typeof normalMinWidth === 'function' ? normalMinWidth() : normalMinWidth;
return Math.min(Math.max(1, Number(minWidth) || 1), 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",
pastSetupNoWarFlagShort: "None",
flag: "War Flag",
flagId: "War Flag {id}",
patternSlotEmpty: "Pattern slot {slot}: empty",
noPatternsEquipped: "No Patterns equipped.",
emptyPatternShort: "None",
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",
updateLatest: "Latest: v{version}",
updateAvailable: "A newer HWCT version is available.",
updateGreasyFork: "Update it from Greasy Fork.",
stateAvailable: "Available",
stateEmpty: "Empty",
stateDefeated: "Defeated",
stateCaptured: "Captured",
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})",
rankLong: "Rank {rank}",
titans: "Titans",
totems: "Totems",
noTotem: "No Totem",
buildingBuff: "Building Buff",
skillShort: "Skill {index}",
rankShort: "R{rank}",
levelShort: "Lv {level}",
pastBattles: "Past Battles",
pastBattlesInfo: "Past Battles information",
pastBattlesTip: "Shows recent real Titan battles where the same five Titans were used on defense. Player identity is not part of the match.",
noMatchingTitanBattleLog: "No matching Titan battle log found.",
attacker: "Attacker",
defender: "Defender",
matchUpper: "MATCH",
winUpper: "WIN",
lossUpper: "LOSS",
drawUpper: "DRAW",
unknownResult: "Result unknown",
battleBuff: "Battle Buff",
sameTitanMatches: "Latest {count} matching battle(s)",
}),
"ru": Object.freeze({
settings: "Настройки",
minimize: "Свернуть",
close: "Закрыть",
fontSize: "Размер шрифта",
save: "Сохранить",
reset: "Сбросить",
combatTraining: "Тренировочный бой",
defenseReference: "Данные защиты",
defense: "Защита",
currentDefense: "Текущая защита",
warFlagId: "Боевой флаг {id}",
noWarFlag: "Нет боевого флага",
pastSetupNoWarFlagShort: "Нет",
flag: "Боевой флаг",
flagId: "Боевой флаг {id}",
patternSlotEmpty: "Ячейка узора {slot}: пусто",
noPatternsEquipped: "Узоры не экипированы.",
emptyPatternShort: "Нет",
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: "Открыть «Тренировочный бой»",
updateLatest: "Последняя: v{version}",
updateAvailable: "Доступна новая версия HWCT.",
updateGreasyFork: "Обновите её с Greasy Fork.",
stateAvailable: "Доступно",
stateEmpty: "Пусто",
stateDefeated: "Побеждён",
stateCaptured: "Захвачено",
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})",
rankLong: "Ранг {rank}",
}),
"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",
pastSetupNoWarFlagShort: "Nenhuma",
flag: "Bandeira de Guerra",
flagId: "Bandeira de Guerra {id}",
patternSlotEmpty: "Slot de emblema {slot}: vazio",
noPatternsEquipped: "Nenhum emblema equipado.",
emptyPatternShort: "Vazio",
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",
updateLatest: "Mais recente: v{version}",
updateAvailable: "Uma versão mais recente do HWCT está disponível.",
updateGreasyFork: "Atualize pelo Greasy Fork.",
stateAvailable: "Disponível",
stateEmpty: "Vazio",
stateDefeated: "Derrotado",
stateCaptured: "Capturado",
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})",
rankLong: "Rank {rank}",
}),
"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",
pastSetupNoWarFlagShort: "Aucun",
flag: "Drapeau de guerre",
flagId: "Drapeau de guerre {id}",
patternSlotEmpty: "Emplacement de motif {slot} : vide",
noPatternsEquipped: "Aucun motif équipé.",
emptyPatternShort: "Vide",
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",
updateLatest: "Dernière : v{version}",
updateAvailable: "Une version plus récente de HWCT est disponible.",
updateGreasyFork: "Mettez-la à jour depuis Greasy Fork.",
stateAvailable: "Disponible",
stateEmpty: "Vide",
stateDefeated: "Vaincu",
stateCaptured: "Capturé",
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})",
rankLong: "Rang {rank}",
}),
"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",
pastSetupNoWarFlagShort: "Nessuna",
flag: "Bandiera di guerra",
flagId: "Bandiera di guerra {id}",
patternSlotEmpty: "Slot Disegno {slot}: vuoto",
noPatternsEquipped: "Nessun Disegno equipaggiato.",
emptyPatternShort: "Vuoto",
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",
updateLatest: "Più recente: v{version}",
updateAvailable: "È disponibile una versione più recente di HWCT.",
updateGreasyFork: "Aggiornala da Greasy Fork.",
stateAvailable: "Disponibile",
stateEmpty: "Vuoto",
stateDefeated: "Sconfitto",
stateCaptured: "Conquistato",
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})",
rankLong: "Rango {rank}",
}),
"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",
pastSetupNoWarFlagShort: "Keine",
flag: "Kriegsflagge",
flagId: "Kriegsflagge {id}",
patternSlotEmpty: "Musterplatz {slot}: leer",
noPatternsEquipped: "Keine Muster ausgerüstet.",
emptyPatternShort: "Leer",
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",
updateLatest: "Neueste: v{version}",
updateAvailable: "Eine neuere HWCT-Version ist verfügbar.",
updateGreasyFork: "Aktualisiere sie über Greasy Fork.",
stateAvailable: "Verfügbar",
stateEmpty: "Leer",
stateDefeated: "Besiegt",
stateCaptured: "Erobert",
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})",
rankLong: "Rang {rank}",
}),
"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",
pastSetupNoWarFlagShort: "Ninguna",
flag: "Bandera de guerra",
flagId: "Bandera de guerra {id}",
patternSlotEmpty: "Ranura de diseño {slot}: vacía",
noPatternsEquipped: "Sin diseños equipados.",
emptyPatternShort: "Vacío",
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",
updateLatest: "Más reciente: v{version}",
updateAvailable: "Hay una versión más reciente de HWCT.",
updateGreasyFork: "Actualízala desde Greasy Fork.",
stateAvailable: "Disponible",
stateEmpty: "Vacío",
stateDefeated: "Derrotado",
stateCaptured: "Capturado",
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})",
rankLong: "Rango {rank}",
}),
"zh-CN": Object.freeze({
settings: "设置",
minimize: "最小化",
close: "关闭",
fontSize: "字体大小",
save: "保存",
reset: "重置",
combatTraining: "作战训练",
defenseReference: "防守参考",
defense: "防守",
currentDefense: "当前防守",
warFlagId: "战旗 {id}",
noWarFlag: "无战旗",
pastSetupNoWarFlagShort: "无",
flag: "战旗",
flagId: "战旗 {id}",
patternSlotEmpty: "图案槽位 {slot}:空",
noPatternsEquipped: "未装备图案。",
emptyPatternShort: "无",
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: "打开作战训练",
updateLatest: "最新版本:v{version}",
updateAvailable: "有更新版本的 HWCT 可用。",
updateGreasyFork: "请从 Greasy Fork 更新。",
stateAvailable: "可用",
stateEmpty: "空",
stateDefeated: "已击败",
stateCaptured: "已占领",
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})",
rankLong: "等级 {rank}",
}),
"zh-TW": Object.freeze({
settings: "設定",
minimize: "最小化",
close: "關閉",
fontSize: "字體大小",
save: "儲存",
reset: "重設",
combatTraining: "對戰訓練",
defenseReference: "防守參考",
defense: "防守",
currentDefense: "目前防守",
warFlagId: "戰旗 {id}",
noWarFlag: "無戰旗",
pastSetupNoWarFlagShort: "無",
flag: "戰旗",
flagId: "戰旗 {id}",
patternSlotEmpty: "圖案欄位 {slot}:空",
noPatternsEquipped: "未裝備圖案。",
emptyPatternShort: "無",
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: "開啟對戰訓練",
updateLatest: "最新版本:v{version}",
updateAvailable: "有較新版本的 HWCT 可用。",
updateGreasyFork: "請從 Greasy Fork 更新。",
stateAvailable: "可用",
stateEmpty: "空",
stateDefeated: "已擊敗",
stateCaptured: "已佔領",
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})",
rankLong: "等級 {rank}",
}),
"ja": Object.freeze({
settings: "設定",
minimize: "最小化",
close: "閉じる",
fontSize: "文字サイズ",
save: "保存",
reset: "リセット",
combatTraining: "戦闘訓練",
defenseReference: "防衛参照",
defense: "防衛",
currentDefense: "現在の防衛",
warFlagId: "戦旗 {id}",
noWarFlag: "戦旗なし",
pastSetupNoWarFlagShort: "なし",
flag: "戦旗",
flagId: "戦旗 {id}",
patternSlotEmpty: "模様スロット {slot}: 空",
noPatternsEquipped: "模様なし",
emptyPatternShort: "なし",
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: "戦闘訓練を開く",
updateLatest: "最新: v{version}",
updateAvailable: "新しいバージョンのHWCTがあります。",
updateGreasyFork: "Greasy Forkから更新できます。",
stateAvailable: "選択可能",
stateEmpty: "空",
stateDefeated: "撃破済み",
stateCaptured: "占領済み",
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})",
rankLong: "ランク{rank}",
}),
"ko": Object.freeze({
settings: "설정",
minimize: "최소화",
close: "닫기",
fontSize: "글꼴 크기",
save: "저장",
reset: "초기화",
combatTraining: "전투 훈련",
defenseReference: "방어 참고",
defense: "방어",
currentDefense: "현재 방어",
warFlagId: "전쟁 깃발 {id}",
noWarFlag: "전쟁 깃발 없음",
pastSetupNoWarFlagShort: "없음",
flag: "전쟁 깃발",
flagId: "전쟁 깃발 {id}",
patternSlotEmpty: "패턴 슬롯 {slot}: 비어 있음",
noPatternsEquipped: "장착한 패턴 없음",
emptyPatternShort: "없음",
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: "전투 훈련 열기",
updateLatest: "최신: v{version}",
updateAvailable: "더 새로운 HWCT 버전을 사용할 수 있습니다.",
updateGreasyFork: "Greasy Fork에서 업데이트하세요.",
stateAvailable: "선택 가능",
stateEmpty: "비어 있음",
stateDefeated: "격파됨",
stateCaptured: "점령됨",
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})",
rankLong: "랭크 {rank}",
}),
"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",
pastSetupNoWarFlagShort: "Brak",
flag: "Flaga wojenna",
flagId: "Flaga wojenna {id}",
patternSlotEmpty: "Miejsce wzoru {slot}: puste",
noPatternsEquipped: "Brak założonych wzorów.",
emptyPatternShort: "Brak",
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",
updateLatest: "Najnowsza: v{version}",
updateAvailable: "Dostępna jest nowsza wersja HWCT.",
updateGreasyFork: "Zaktualizuj ją z Greasy Fork.",
stateAvailable: "Dostępne",
stateEmpty: "Puste",
stateDefeated: "Pokonany",
stateCaptured: "Zdobyte",
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})",
rankLong: "Ranga {rank}",
}),
"th": Object.freeze({
settings: "การตั้งค่า",
minimize: "ย่อ",
close: "ปิด",
fontSize: "ขนาดตัวอักษร",
save: "บันทึก",
reset: "รีเซ็ต",
combatTraining: "การฝึกซ้อมการต่อสู้",
defenseReference: "ข้อมูลอ้างอิงการป้องกัน",
defense: "การป้องกัน",
currentDefense: "การป้องกันปัจจุบัน",
warFlagId: "ธงสงคราม {id}",
noWarFlag: "ไม่มีธงสงคราม",
pastSetupNoWarFlagShort: "ไม่มี",
flag: "ธงสงคราม",
flagId: "ธงสงคราม {id}",
patternSlotEmpty: "ช่องรูปแบบ {slot}: ว่าง",
noPatternsEquipped: "ไม่มีรูปแบบที่ใส่",
emptyPatternShort: "ว่าง",
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: "เปิดการฝึกซ้อมการต่อสู้",
updateLatest: "ล่าสุด: v{version}",
updateAvailable: "มี HWCT เวอร์ชันใหม่กว่าให้ใช้งาน",
updateGreasyFork: "อัปเดตจาก Greasy Fork",
stateAvailable: "พร้อมใช้งาน",
stateEmpty: "ว่าง",
stateDefeated: "ถูกกำจัด",
stateCaptured: "ยึดแล้ว",
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})",
rankLong: "แรงก์ {rank}",
}),
});
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
);
}
const updateAvailabilityState = {
latestVersion: null,
updateAvailable: false,
checkedAt: 0,
listeners: new Set(),
checkPromise: null,
lastError: null,
};
function parseStableVersion(value) {
const match = String(value ?? '').trim().match(/^v?(\d+)\.(\d+)\.(\d+)$/);
return match ? match.slice(1).map(Number) : null;
}
function compareStableVersions(a, b) {
const left = parseStableVersion(a);
const right = parseStableVersion(b);
if (!left || !right) return null;
for (let i = 0; i < 3; i += 1) {
if (left[i] !== right[i]) return left[i] < right[i] ? -1 : 1;
}
return 0;
}
function getInstalledStableVersionForUpdateCheck() {
const match = String(VERSION).match(/^(\d+\.\d+\.\d+)(?:-|$)/);
return match ? match[1] : null;
}
function notifyUpdateAvailabilityListeners() {
for (const listener of [...updateAvailabilityState.listeners]) {
try { listener({ ...updateAvailabilityState }); } catch {}
}
}
function applyLatestPublishedVersion(latestVersion, checkedAt = Date.now()) {
const installedVersion = getInstalledStableVersionForUpdateCheck();
const comparison = installedVersion ? compareStableVersions(installedVersion, latestVersion) : null;
updateAvailabilityState.latestVersion = parseStableVersion(latestVersion) ? String(latestVersion).replace(/^v/, '') : null;
updateAvailabilityState.lastError = null;
updateAvailabilityState.updateAvailable = comparison === -1;
updateAvailabilityState.checkedAt = Number(checkedAt) || 0;
notifyUpdateAvailabilityListeners();
}
function loadUpdateCheckCache() {
try {
const raw = JSON.parse(localStorage.getItem(UPDATE_CHECK_STORAGE_KEY) || 'null');
if (!raw || typeof raw !== 'object') return null;
const latestVersion = parseStableVersion(raw.latestVersion) ? String(raw.latestVersion).replace(/^v/, '') : null;
const checkedAt = Number(raw.checkedAt) || 0;
if (latestVersion) applyLatestPublishedVersion(latestVersion, checkedAt);
else updateAvailabilityState.checkedAt = checkedAt;
return { latestVersion, checkedAt };
} catch {
return null;
}
}
function saveUpdateCheckCache(latestVersion, checkedAt) {
try {
localStorage.setItem(UPDATE_CHECK_STORAGE_KEY, JSON.stringify({
latestVersion: parseStableVersion(latestVersion) ? String(latestVersion).replace(/^v/, '') : null,
checkedAt: Number(checkedAt) || Date.now(),
}));
} catch {}
}
async function fetchLatestGreasyForkVersion() {
const response = await fetch(GREASY_FORK_JSON_URL, {
method: 'GET',
mode: 'cors',
credentials: 'omit',
cache: 'no-store',
headers: { Accept: 'application/json' },
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
const candidates = [
data?.version,
data?.code_version,
data?.script?.version,
data?.script?.code_version,
];
for (const candidate of candidates) {
if (parseStableVersion(candidate)) return String(candidate).replace(/^v/, '');
}
throw new Error('Greasy Fork version not found');
}
function ensureUpdateAvailabilityCheck() {
if (updateAvailabilityState.checkPromise) return updateAvailabilityState.checkPromise;
const cache = loadUpdateCheckCache();
const now = Date.now();
const lastAttemptAt = Number(cache?.checkedAt || updateAvailabilityState.checkedAt) || 0;
if (lastAttemptAt && now - lastAttemptAt < UPDATE_CHECK_INTERVAL_MS) {
return Promise.resolve(updateAvailabilityState.latestVersion);
}
// Record the attempt time before the network call so repeated failures can never
// cause Greasy Fork requests more often than the approved 24-hour interval.
saveUpdateCheckCache(cache?.latestVersion || updateAvailabilityState.latestVersion, now);
updateAvailabilityState.checkedAt = now;
updateAvailabilityState.checkPromise = fetchLatestGreasyForkVersion()
.then(latestVersion => {
saveUpdateCheckCache(latestVersion, now);
applyLatestPublishedVersion(latestVersion, now);
return latestVersion;
})
.catch(error => {
// Fail-soft: keep any cached result and never affect Combat Training/Reference.
updateAvailabilityState.lastError = String(error?.message || error || 'update check failed');
notifyUpdateAvailabilityListeners();
return updateAvailabilityState.latestVersion;
})
.finally(() => {
updateAvailabilityState.checkPromise = null;
});
return updateAvailabilityState.checkPromise;
}
function subscribeUpdateAvailability(listener) {
if (typeof listener !== 'function') return () => {};
updateAvailabilityState.listeners.add(listener);
try { listener({ ...updateAvailabilityState }); } catch {}
return () => updateAvailabilityState.listeners.delete(listener);
}
function updateTooltipText(latestVersion) {
const version = latestVersion || '?';
return `${t('updateLatest', { version })}\n${t('updateAvailable')}\n${t('updateGreasyFork')}`;
}
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',
spiritVO: 'game.mediator.gui.popup.team.SpiritValueObject',
playerTitanSpiritArtifact: 'game.model.user.hero.PlayerTitanSpiritArtifact',
playerTitanSpiritSkill: 'game.model.user.hero.PlayerTitanSpiritSkill',
titanArtifactDescription: 'game.data.storage.artifact.TitanArtifactDescription',
titanSpiritSkillDescription: 'game.data.storage.artifact.TitanSpiritSkillDescription',
skillDescription: 'game.data.storage.skills.SkillDescription',
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',
titanEntryVO: 'game.mediator.gui.popup.titan.TitanEntryValueObject',
titanDescription: 'game.data.storage.titan.TitanDescription',
subTexture: 'starling.textures.SubTexture',
concreteTexture: 'starling.textures.ConcreteTexture',
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',
skillIconAssetStorage: 'game.assets.storage.SkillIconAssetStorage',
iconContentProvider: 'game.view.gui.components.inventory.IconContentProvider',
atlasTextureIconAsset: 'game.assets.icon.AtlasTextureIconAsset',
rsxIconAsset: 'game.assets.RsxIconAsset',
textureIconAsset: 'game.assets.TextureIconAsset',
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 TITAN_FRAME_DISPLAY_SIZE = 46;
const TITAN_PORTRAIT_DISPLAY_SIZE = TITAN_FRAME_DISPLAY_SIZE * 80 / 96;
const TITAN_PORTRAIT_DISPLAY_INSET = (TITAN_FRAME_DISPLAY_SIZE - TITAN_PORTRAIT_DISPLAY_SIZE) / 2;
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 unitIconDiagnostics = new Map();
const imageSizeCache = new Map();
const titanFrameSpecCache = new Map();
const titanFrameSpecPromiseCache = 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 titanSpiritSkillCatalogCache = null;
let titanSpiritDescriptionCatalogCache = null;
let titanSpiritSkillFieldRolesCache = null;
let titanSpiritArtifactStatFieldRolesCache = null;
let titanArtifactIconMapperMethodCache = null;
let titanSkillIconStorageCache = null;
let titanSkillIconResolverCache = null;
const titanTotemIconSpecCache = new Map();
const titanTotemIconSpecPromiseCache = new Map();
const titanSkillIconSpecCache = new Map();
const titanSkillIconSpecPromiseCache = new Map();
let defeatedDisplayStyle = 'cross';
function getTitanTotemTierByLevel(level) {
const n = Number(level);
if (!Number.isFinite(n) || n <= 0) return 'white';
if (n <= 25) return 'white';
if (n <= 50) return 'green';
if (n <= 70) return 'blue';
if (n <= 95) return 'violet';
if (n <= 120) return 'orange';
return 'red';
}
function getTitanSkillTierByRank(rank) {
const n = Number(rank);
if (!Number.isFinite(n) || n <= 1) return 'white';
if (n === 2) return 'green';
if (n === 3) return 'blue';
if (n === 4) return 'violet';
if (n === 5) return 'orange';
return 'red';
}
function toRomanNumeral(value) {
const n = Math.trunc(Number(value));
if (!Number.isFinite(n) || n <= 0) return '';
const numerals = [
['M', 1000], ['CM', 900], ['D', 500], ['CD', 400], ['C', 100], ['XC', 90],
['L', 50], ['XL', 40], ['X', 10], ['IX', 9], ['V', 5], ['IV', 4], ['I', 1]
];
let remaining = n;
let out = '';
for (const [glyph, amount] of numerals) {
while (remaining >= amount) {
out += glyph;
remaining -= amount;
}
}
return out;
}
function formatTitanRankShort(rank) {
const roman = toRomanNumeral(rank);
return roman || (rank == null ? '' : String(rank));
}
function getTitanTierFrameStyle(tier) {
switch (String(tier || 'white')) {
case 'green':
return {
border: '3px solid rgba(110, 216, 126, 0.98)',
inner: 'inset 0 0 0 1px rgba(255,255,255,0.18)',
glow: '0 0 0 1px rgba(52,110,58,0.58), 0 0 8px rgba(110,216,126,0.22)'
};
case 'blue':
return {
border: '3px solid rgba(98, 176, 255, 0.99)',
inner: 'inset 0 0 0 1px rgba(255,255,255,0.18)',
glow: '0 0 0 1px rgba(46,86,126,0.58), 0 0 8px rgba(98,176,255,0.24)'
};
case 'violet':
return {
border: '3px solid rgba(174, 118, 255, 0.99)',
inner: 'inset 0 0 0 1px rgba(255,255,255,0.16)',
glow: '0 0 0 1px rgba(86,52,126,0.60), 0 0 9px rgba(174,118,255,0.24)'
};
case 'orange':
return {
border: '3px solid rgba(255, 179, 72, 0.99)',
inner: 'inset 0 0 0 1px rgba(255,255,255,0.16)',
glow: '0 0 0 1px rgba(126,78,28,0.62), 0 0 9px rgba(255,179,72,0.26)'
};
case 'red':
return {
border: '3px solid rgba(255, 99, 99, 0.99)',
inner: 'inset 0 0 0 1px rgba(255,255,255,0.16)',
glow: '0 0 0 1px rgba(126,40,40,0.62), 0 0 10px rgba(255,99,99,0.28)'
};
default:
return {
border: '3px solid rgba(236, 236, 236, 0.98)',
inner: 'inset 0 0 0 1px rgba(255,255,255,0.18)',
glow: '0 0 0 1px rgba(88,88,88,0.54)'
};
}
}
function applyTitanTierFrame(element, tier, size) {
if (!element) return;
const style = getTitanTierFrameStyle(tier);
if (Number.isFinite(Number(size)) && Number(size) > 0) {
element.style.width = `calc(${Number(size)}px * var(--hwct-icon-scale, 1))`;
element.style.height = `calc(${Number(size)}px * var(--hwct-icon-scale, 1))`;
element.style.flex = `0 0 calc(${Number(size)}px * var(--hwct-icon-scale, 1))`;
}
element.style.boxSizing = 'border-box';
element.style.borderRadius = 'calc(8px * var(--hwct-icon-scale, 1))';
element.style.border = style.border;
element.style.boxShadow = `${style.inner}, ${style.glow}`;
element.style.background = 'rgba(255,255,255,0.03)';
element.style.display = 'inline-flex';
element.style.alignItems = 'center';
element.style.justifyContent = 'center';
element.style.overflow = 'hidden';
}
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 getGwTitanStateMap(slot, team) {
if (!Array.isArray(team) || !team.length) return null;
const defender = callSemantic(slot, 'get_defender', { optional: true });
if (!defender) return null;
const hp = callSemantic(defender, 'get_hpPercentState', { optional: true });
if (!Array.isArray(hp) || hp.length !== team.length) return null;
const stateById = new Map();
for (let index = 0; index < team.length; index += 1) {
const id = Number(getTitanUnitId(team[index]));
const hpPercent = Number(hp[index]);
if (!Number.isFinite(id) || !Number.isFinite(hpPercent)) return null;
stateById.set(id, { hpPercent, alive: hpPercent > 0 });
}
return stateById.size === team.length ? stateById : null;
}
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 getCoWTitanStateMap(slot, team) {
if (!Array.isArray(team) || !team.length) return null;
try {
const currentSlots = uniqueRefs(
Object.values(slot).filter(value => className(value) === CLASS.cowCurrentSlot)
);
if (currentSlots.length !== 1) return null;
const defenderProperty = callSemantic(currentSlots[0], 'get_defender', { optional: true });
if (!defenderProperty || typeof defenderProperty !== 'object') return null;
const teamStates = uniqueRefs(
Object.values(defenderProperty).filter(value => className(value) === CLASS.cowBattleTeamWithState)
);
if (teamStates.length !== 1 || !Array.isArray(teamStates[0].units)) return null;
const expectedIds = new Set();
for (const unit of team) {
const id = Number(getTitanUnitId(unit));
if (Number.isFinite(id)) expectedIds.add(id);
}
if (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(getTitanUnitId(unit));
if (!Number.isFinite(id) || !expectedIds.has(id)) continue;
const hp = Number(state.hp);
const defeatedFlag = state?.Yr === true;
stateById.set(id, {
hp: Number.isFinite(hp) ? hp : null,
alive: Number.isFinite(hp) ? hp > 0 : !defeatedFlag,
});
}
// Fail closed: only show defeated markers when all five original Titans
// have a corresponding live UnitState.
if (stateById.size !== expectedIds.size) return null;
return stateById;
} catch (error) {
warn('COW_TITAN_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);
const normalizedTeam = Array.isArray(team) ? team : [];
const displayState = (
context.kind === 'GW' &&
state === 'defeated' &&
normalizedTeam.length === 0
) ? 'captured' : state;
return {
slot,
slotNumber,
state,
displayState,
team: normalizedTeam,
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];
const isHero = Boolean(callSemantic(match.slot, 'get_isHeroSlot', { optional: true }));
const semanticSlotNumber = Number(callSemantic(match.slot, 'get_slotNumber', { optional: true }));
const listPosition = match.index + 1;
const targetOptions = {
slotNumber: Number.isFinite(semanticSlotNumber) ? semanticSlotNumber : listPosition,
listPosition,
building: getSlotBuildingName(CONTEXT.COW, match.slot),
mediator,
popup,
};
const target = isHero
? buildPatronTargetFromSlot('CoW', match.slot, targetOptions)
: buildTitanReferenceTargetFromSlot('CoW', match.slot, targetOptions);
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 getReferenceSessionTarget(session) {
return session?.target ?? session?.patronTarget ?? null;
}
function getNativeCowSessionKey(target) {
return ['CoW', target?.listPosition ?? target?.slotNumber ?? '', target?.playerId ?? '', target?.heroKey ?? target?.titanKey ?? ''].join('|');
}
function hideReferenceForAttackEditor(session) {
if (!session) return;
session.referenceHiddenForAttackEditor = true;
session.attackEditorClosedAt = 0;
patronReferenceView?.savePosition?.();
patronReferenceView?.remove?.();
patronReferenceView = null;
hideReferenceReopenLauncher();
if (getReferenceSessionTarget(session)?.kind === 'GW') mainPanelController?.hideForReference?.();
}
function restoreReferenceAfterAttackEditor(session) {
if (!session?.referenceHiddenForAttackEditor) return false;
session.referenceHiddenForAttackEditor = false;
session.attackEditorSeen = false;
session.attackEditorClosedAt = 0;
return showActivePatronReference();
}
function handleNativeCowDemoOpened(demoMediator, demoPopup) {
let resolved = null;
try { resolved = resolveNativeCowTarget(demoMediator); }
catch (error) { warn('COW_NATIVE_TARGET_RESOLVE_FAILED', error); }
if (!resolved?.target) return;
const target = resolved.target;
const key = getNativeCowSessionKey(target);
if (nativeCowSession?.key === key) {
nativeCowSession.popup = demoPopup;
nativeCowSession.demoMediator = demoMediator;
nativeCowSession.battleLaunchBaseline = captureBattleLaunchBaseline(demoMediator);
nativeCowSession.popupDisposedAt = 0;
nativeCowSession.attackEditorClosedAt = 0;
log('CoW native target reused', {
position: target.listPosition,
player: target.playerName,
referenceType: target.referenceType || 'hero',
teamKey: target.heroKey || target.titanKey || '',
});
if (nativeCowSession.referenceHiddenForAttackEditor && !hasOpenAttackEditor()) {
restoreReferenceAfterAttackEditor(nativeCowSession);
} else if (!nativeCowSession.referenceHiddenForAttackEditor && !patronReferenceView && !referenceReopenLauncher) {
showActivePatronReference();
}
return;
}
hidePatronReference({ restoreMain: false });
nativeCowSession = {
key,
popup: demoPopup,
demoMediator,
battleLaunchBaseline: captureBattleLaunchBaseline(demoMediator),
attackEditorSeen: false,
attackEditorClosedAt: 0,
popupDisposedAt: 0,
referenceHiddenForAttackEditor: false,
target,
referenceResult: null,
referenceError: null,
referenceLoading: false,
};
log('CoW native target', {
position: target.listPosition,
player: target.playerName,
referenceType: target.referenceType || 'hero',
teamKey: target.heroKey || target.titanKey || '',
});
startPatronReferenceLoad(target, nativeCowSession);
}
function handleNativeCowDemoDisposed(demoPopup) {
if (!nativeCowSession || nativeCowSession.popup !== demoPopup) return;
const session = nativeCowSession;
session.popup = null;
session.popupDisposedAt = Date.now();
[0, 120, 400].forEach(delay => {
window.setTimeout(() => {
if (nativeCowSession !== session || session.popup) return;
if (hasOpenAttackEditor()) {
session.attackEditorSeen = true;
hideReferenceForAttackEditor(session);
}
}, delay);
});
}
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 directValuesByClass(obj, fullName) {
try { return uniqueRefs(Object.values(obj ?? {}).filter(value => className(value) === fullName)); }
catch { return []; }
}
function getTitanUnitId(unit) {
const direct = Number(callSemantic(unit, 'get_id', { optional: true }));
if (Number.isFinite(direct)) return direct;
const titan = callSemantic(unit, 'get_titan', { optional: true });
const titanId = Number(titan ? callSemantic(titan, 'get_id', { optional: true }) : NaN);
if (Number.isFinite(titanId)) return titanId;
const entry = callSemantic(unit, 'get_titanEntry', { optional: true });
const entryTitan = entry ? callSemantic(entry, 'get_titan', { optional: true }) : null;
const entryId = Number(entryTitan ? callSemantic(entryTitan, 'get_id', { optional: true }) : NaN);
if (Number.isFinite(entryId)) return entryId;
try {
for (const child of Object.values(unit ?? {})) {
if (className(child) !== 'game.data.storage.titan.TitanDescription') continue;
const semanticId = Number(callSemantic(child, 'get_id', { optional: true }));
if (Number.isFinite(semanticId)) return semanticId;
const rawId = Number(child?._id);
if (Number.isFinite(rawId)) return rawId;
}
} catch {}
return null;
}
function getTitanUnitName(unit, id = null) {
try {
const titan = callSemantic(unit, 'get_titan', { optional: true });
if (titan?.ri != null && String(titan.ri).trim()) return String(titan.ri).trim();
} catch {}
const numericId = Number(id);
if (Number.isFinite(numericId)) {
try {
const desc = getUnitDescription(numericId);
if (desc?.ri != null && String(desc.ri).trim()) return String(desc.ri).trim();
} catch {}
}
return Number.isFinite(numericId) ? `Titan ${numericId}` : 'Titan';
}
function getTitanSpiritArtifact(spirit) {
let artifact = directValuesByClass(spirit, CLASS.playerTitanSpiritArtifact)[0] ?? null;
if (artifact) return artifact;
try {
for (const child of Object.values(spirit ?? {})) {
if (!child || typeof child !== 'object') continue;
artifact = directValuesByClass(child, CLASS.playerTitanSpiritArtifact)[0] ?? null;
if (artifact) return artifact;
}
} catch {}
return null;
}
function getTitanSpiritDescription(spirit, artifact = null) {
return directValuesByClass(artifact, CLASS.titanArtifactDescription)[0]
?? directValuesByClass(spirit, CLASS.titanArtifactDescription)[0]
?? null;
}
function getTitanSpiritSkillCatalog() {
if (titanSpiritSkillCatalogCache) return titanSpiritSkillCatalogCache;
const SkillDescription = findClass(CLASS.skillDescription, { optional: true });
const DataStorage = findClass(CLASS.dataStorage, { optional: true });
const catalog = new Map();
if (!SkillDescription || !DataStorage) {
titanSpiritSkillCatalogCache = catalog;
return catalog;
}
const seen = new Set();
const queue = Object.values(DataStorage).map(value => ({ value, depth: 0 }));
let cursor = 0;
let scanned = 0;
while (cursor < queue.length && scanned < 12000) {
const { value, depth } = queue[cursor++];
if (!value || (typeof value !== 'object' && typeof value !== 'function') || seen.has(value)) continue;
seen.add(value);
scanned += 1;
if (value?.__class__ === SkillDescription) {
const semanticId = Number(callSemantic(value, 'get_id', { optional: true }));
const rawId = Number(value?._id ?? value?.id);
const id = Number.isFinite(semanticId) ? semanticId : rawId;
if (Number.isFinite(id) && id >= 4500 && id < 4600) catalog.set(id, value);
continue;
}
if (depth >= 7) continue;
if (typeof Node !== 'undefined' && value instanceof Node) continue;
if (typeof ArrayBuffer !== 'undefined' && (ArrayBuffer.isView(value) || value instanceof ArrayBuffer)) continue;
let children = [];
try { children = Object.values(value); } catch { continue; }
for (const child of children) {
if (!child || (typeof child !== 'object' && typeof child !== 'function')) continue;
queue.push({ value: child, depth: depth + 1 });
}
}
titanSpiritSkillCatalogCache = catalog;
return catalog;
}
function getTitanSpiritSkillInfo(skillId) {
const id = Number(skillId);
if (!Number.isFinite(id) || id <= 0) return { id: null, name: '', type: '', description: '' };
const description = getTitanSpiritSkillCatalog().get(id) ?? null;
if (!description) return { id, name: '', type: '', description: '' };
const semanticName = callSemantic(description, 'get_name', { optional: true })
?? callSemantic(description, 'get_title', { optional: true });
const name = String(semanticName ?? description.ri ?? description.name ?? '').trim();
const text = String(
callSemantic(description, 'get_description', { optional: true })
?? description.mF
?? description.description
?? ''
).trim();
return { id, name, type: '', description: text, iconDescription: description };
}
function getTitanSpiritDescriptionCatalog() {
if (titanSpiritDescriptionCatalogCache) return titanSpiritDescriptionCatalogCache;
const ArtifactDescription = findClass(CLASS.titanArtifactDescription, { optional: true });
const DataStorage = findClass(CLASS.dataStorage, { optional: true });
const byElement = new Map();
if (!ArtifactDescription || !DataStorage) {
titanSpiritDescriptionCatalogCache = byElement;
return byElement;
}
const seen = new Set();
const queue = Object.values(DataStorage).map(value => ({ value, depth: 0 }));
let cursor = 0;
let scanned = 0;
while (cursor < queue.length && scanned < 12000) {
const { value, depth } = queue[cursor++];
if (!value || (typeof value !== 'object' && typeof value !== 'function') || seen.has(value)) continue;
seen.add(value);
scanned += 1;
if (value?.__class__ === ArtifactDescription) {
// Prefer semantic getters: the raw artifactType/element field names are minified
// and have already changed between live client builds.
const artifactType = String(
callSemantic(value, 'get_artifactType', { optional: true })
?? value?.o_a
?? ''
).trim().toLowerCase();
if (artifactType !== 'spirit') continue;
const element = String(
callSemantic(value, 'get_element', { optional: true })
?? value?.F_a
?? ''
).trim().toLowerCase();
if (element) byElement.set(element, value);
continue;
}
if (depth >= 7) continue;
if (typeof Node !== 'undefined' && value instanceof Node) continue;
if (typeof ArrayBuffer !== 'undefined' && (ArrayBuffer.isView(value) || value instanceof ArrayBuffer)) continue;
let children = [];
try { children = Object.values(value); } catch { continue; }
for (const child of children) {
if (!child || (typeof child !== 'object' && typeof child !== 'function')) continue;
queue.push({ value: child, depth: depth + 1 });
}
}
titanSpiritDescriptionCatalogCache = byElement;
return byElement;
}
function getTitanSpiritDescriptionName(desc, elementValue = '') {
if (desc) {
for (const semanticName of ['get_name', 'get_title']) {
try {
const value = callSemantic(desc, semanticName, { optional: true });
if (value != null && String(value).trim()) return String(value).trim();
} catch {}
}
for (const key of ['ri', 'name', 'title']) {
const value = desc?.[key];
if (value != null && String(value).trim()) return String(value).trim();
}
}
const element = String(elementValue ?? '').trim().toLowerCase();
return element
? `${element.charAt(0).toUpperCase()}${element.slice(1)} Spirit Totem`
: 'Spirit Totem';
}
function getTitanSpiritDescriptionInfoByElement(elementValue) {
const element = String(elementValue ?? '').trim().toLowerCase();
const desc = element ? (getTitanSpiritDescriptionCatalog().get(element) ?? null) : null;
const semanticId = Number(desc ? callSemantic(desc, 'get_id', { optional: true }) : NaN);
const rawId = Number(desc?._id);
const id = Number.isFinite(semanticId) ? semanticId : (Number.isFinite(rawId) ? rawId : null);
return {
id,
element,
name: getTitanSpiritDescriptionName(desc, element),
description: desc,
};
}
function getAssignedFieldBeforeLiteral(source, literal) {
const quoted = [`"${literal}"`, `'${literal}'`];
let pos = -1;
for (const token of quoted) {
const found = source.indexOf(token);
if (found >= 0 && (pos < 0 || found < pos)) pos = found;
}
if (pos < 0) return '';
const prefix = source.slice(0, pos);
const matches = [...prefix.matchAll(/this\.([A-Za-z_$][\w$]*)\s*=/g)];
return matches.length ? matches[matches.length - 1][1] : '';
}
function getTitanSpiritSkillFieldRoles() {
if (titanSpiritSkillFieldRolesCache) return titanSpiritSkillFieldRolesCache;
const Artifact = findClass(CLASS.playerTitanSpiritArtifact, { optional: true });
if (!Artifact) return null;
const candidates = [];
for (const { source } of prototypeMethodsDeep(Artifact)) {
if (!source.includes('elementalSkill') || !source.includes('primalSkill')) continue;
const elementalField = getAssignedFieldBeforeLiteral(source, 'elementalSkill');
const primalField = getAssignedFieldBeforeLiteral(source, 'primalSkill');
if (!elementalField || !primalField || elementalField === primalField) continue;
candidates.push({ elementalField, primalField });
}
const unique = [...new Map(candidates.map(row => [`${row.elementalField}|${row.primalField}`, row])).values()];
if (unique.length !== 1) return null;
titanSpiritSkillFieldRolesCache = unique[0];
return titanSpiritSkillFieldRolesCache;
}
function getTitanSpiritArtifactStatFieldRoles(artifact) {
if (titanSpiritArtifactStatFieldRolesCache) return titanSpiritArtifactStatFieldRolesCache;
if (!artifact) return null;
// Prefer semantic Haxe properties when the client exposes them. These names are
// stable across minification even when the backing field names change.
const semanticCandidates = [
{ role: 'levelField', names: ['get_level'] },
{ role: 'starField', names: ['get_evolution', 'get_evolutionLevel', 'get_star', 'get_stars'] },
];
const resolved = {};
for (const candidate of semanticCandidates) {
for (const semanticName of candidate.names) {
try {
const getter = findGetter(artifact.__class__, semanticName, { optional: true });
if (!getter) continue;
const source = String(artifact.__class__?.prototype?.[getter] ?? '');
const match = /^function\(\)\{return this\.([A-Za-z_$][\w$]*)\}$/.exec(source.replace(/\s+/g, ' '));
if (match) {
resolved[candidate.role] = match[1];
break;
}
} catch {}
}
}
if (resolved.levelField && resolved.starField && resolved.levelField !== resolved.starField) {
titanSpiritArtifactStatFieldRolesCache = resolved;
return titanSpiritArtifactStatFieldRolesCache;
}
// Known live field names are only a compatibility fallback. 2026-08-18/19
// Harness capture confirmed Bf/eN -> Cf/jN after a client update.
const knownLevelFields = ['Cf', 'Bf'];
const knownStarFields = ['jN', 'eN'];
const levelField = knownLevelFields.find(key => artifact?.[key] != null && Number.isFinite(Number(artifact[key])));
const starField = knownStarFields.find(key => artifact?.[key] != null && Number.isFinite(Number(artifact[key])));
if (levelField && starField && levelField !== starField) {
titanSpiritArtifactStatFieldRolesCache = { levelField, starField };
return titanSpiritArtifactStatFieldRolesCache;
}
// Last-resort inference for future builds: PlayerTitanSpiritArtifact currently
// has exactly two primitive numeric state fields (level and evolution) plus
// object-valued skill fields. Only accept an unambiguous pairing.
const primitiveNumbers = Object.entries(artifact)
.filter(([, value]) => typeof value === 'number' && Number.isFinite(value))
.map(([field, value]) => ({ field, value: Number(value) }));
const plausibleStars = primitiveNumbers.filter(row => Number.isInteger(row.value) && row.value >= 0 && row.value <= 6);
const plausibleLevels = primitiveNumbers.filter(row => Number.isInteger(row.value) && row.value >= 0 && row.value <= 130);
const pairings = [];
for (const level of plausibleLevels) {
for (const star of plausibleStars) {
if (level.field === star.field) continue;
// A value above the star cap is strong evidence for the level role. If both
// values are <= 6, do not guess.
if (level.value <= 6) continue;
pairings.push({ levelField: level.field, starField: star.field });
}
}
const unique = [...new Map(pairings.map(row => [`${row.levelField}|${row.starField}`, row])).values()];
if (unique.length === 1) {
titanSpiritArtifactStatFieldRolesCache = unique[0];
return titanSpiritArtifactStatFieldRolesCache;
}
return null;
}
function getTitanSpiritArtifactStats(artifact) {
if (!artifact) return { level: null, star: null };
let level = null;
for (const semanticName of ['get_level']) {
try {
const raw = callSemantic(artifact, semanticName, { optional: true });
if (raw == null) continue;
const value = Number(raw);
if (Number.isFinite(value)) { level = value; break; }
} catch {}
}
let star = null;
for (const semanticName of ['get_evolution', 'get_evolutionLevel', 'get_star', 'get_stars']) {
try {
const raw = callSemantic(artifact, semanticName, { optional: true });
if (raw == null) continue;
const value = Number(raw);
if (Number.isFinite(value)) { star = value; break; }
} catch {}
}
if (level != null && star != null) return { level, star };
const roles = getTitanSpiritArtifactStatFieldRoles(artifact);
if (level == null && roles?.levelField) {
const value = Number(artifact?.[roles.levelField]);
if (Number.isFinite(value)) level = value;
}
if (star == null && roles?.starField) {
const value = Number(artifact?.[roles.starField]);
if (Number.isFinite(value)) star = value;
}
return { level, star };
}
function summarizeTitanSpiritSkills(artifact) {
if (!artifact) return [];
const roles = getTitanSpiritSkillFieldRoles();
if (roles) {
const declared = [
{ type: 'Elemental', field: roles.elementalField },
{ type: 'Primal', field: roles.primalField },
];
const rows = declared.map((role, index) => {
const skill = artifact?.[role.field];
if (className(skill) !== CLASS.playerTitanSpiritSkill) return null;
const skillId = Number(skill?.id);
const rank = Number(skill?.level);
const info = getTitanSpiritSkillInfo(skillId);
return {
index: index + 1,
field: role.field,
id: Number.isFinite(skillId) ? skillId : null,
rank: Number.isFinite(rank) ? rank : null,
name: info.name,
type: role.type,
description: info.description,
iconDescription: info.iconDescription ?? null,
};
}).filter(Boolean);
if (rows.length) return rows;
}
// Fail-soft fallback for an unexpected client build. Limit to two current
// skill objects so last-roll skill objects are not accidentally rendered.
return Object.entries(artifact)
.filter(([, value]) => className(value) === CLASS.playerTitanSpiritSkill)
.slice(0, 2)
.map(([field, skill], index) => {
const skillId = Number(skill?.id);
const rank = Number(skill?.level);
const info = getTitanSpiritSkillInfo(skillId);
return {
index: index + 1,
field,
id: Number.isFinite(skillId) ? skillId : null,
rank: Number.isFinite(rank) ? rank : null,
name: info.name,
type: info.type,
description: info.description,
iconDescription: info.iconDescription ?? null,
};
});
}
function summarizeTitanSpirit(spirit) {
if (!spirit) return null;
const idRaw = callSemantic(spirit, 'get_id', { optional: true });
const id = Number(idRaw);
if (!Number.isFinite(id) || id <= 0) return null;
const artifact = getTitanSpiritArtifact(spirit);
const desc = getTitanSpiritDescription(spirit, artifact);
const element = String(
(desc ? callSemantic(desc, 'get_element', { optional: true }) : null)
?? spirit.element
?? desc?.F_a
?? ''
).trim();
const catalogInfo = getTitanSpiritDescriptionInfoByElement(element);
const directName = getTitanSpiritDescriptionName(desc, element);
const name = directName || catalogInfo.name || `Totem ${id}`;
const { level, star } = getTitanSpiritArtifactStats(artifact);
const skills = summarizeTitanSpiritSkills(artifact);
return {
id,
element,
name,
level,
// The live Artifact evolution value is the Totem star count. Current-vs-MAX
// Harness capture confirmed Current jN=2/5 -> MAX jN=6 in the 2026-08-19 client.
star,
skills,
// Keep the native TitanArtifactDescription so the renderer can ask Hero Wars'
// generic description->IconAsset mapper for the real Totem artwork.
iconDescription: desc ?? catalogInfo.description ?? null,
};
}
function getTitanSpiritsFromSlot(slot) {
const collection = callSemantic(slot, 'get_spirits', { optional: true });
let values = [];
if (Array.isArray(collection)) values = collection.slice();
else if (collection?.__class__?.j === CLASS.listCollection) values = getCollectionData(collection) ?? [];
else {
try {
values = Object.values(collection ?? {}).find(value =>
Array.isArray(value) && value.every(item => !item || className(item) === CLASS.spiritVO)
) ?? [];
} catch {}
}
return values
.map(summarizeTitanSpirit)
.filter(row => {
if (!row) return false;
// Current client may expose an unowned elemental Totem as a placeholder
// SpiritValueObject with level=0, evolution/star=0 and no Fusion Skills.
// It is not part of the real defense and must render as No Totem.
const unownedPlaceholder = Number(row.level) === 0
&& Number(row.star) === 0
&& (!Array.isArray(row.skills) || row.skills.length === 0);
return !unownedPlaceholder;
})
.slice(0, 2);
}
function stripHeroWarsColorMarkup(value) {
return String(value ?? '').replace(/\^\{[^}]+\}\^/g, '').trim();
}
function findObjectsByClassLimited(roots, targetClass, { maxDepth = 7, maxNodes = 1600 } = {}) {
const queue = (Array.isArray(roots) ? roots : [roots])
.filter(Boolean)
.map(value => ({ value, depth: 0 }));
const seen = new Set();
const found = [];
let scanned = 0;
while (queue.length && scanned < maxNodes) {
const { value, depth } = queue.shift();
if (!value || (typeof value !== 'object' && typeof value !== 'function') || seen.has(value)) continue;
seen.add(value);
scanned += 1;
if (className(value) === targetClass) {
found.push(value);
continue;
}
if (depth >= maxDepth) continue;
if (typeof Node !== 'undefined' && value instanceof Node) continue;
if (typeof ArrayBuffer !== 'undefined' && (ArrayBuffer.isView(value) || value instanceof ArrayBuffer)) continue;
let children = [];
try { children = Object.values(value); } catch { continue; }
for (const child of children) {
if (!child || (typeof child !== 'object' && typeof child !== 'function')) continue;
if (child === window || child === document || child === globalThis) continue;
if (typeof Node !== 'undefined' && child instanceof Node) continue;
if (typeof ArrayBuffer !== 'undefined' && (ArrayBuffer.isView(child) || child instanceof ArrayBuffer)) continue;
if (Array.isArray(child)) {
for (const nested of child.slice(0, 100)) {
if (nested && (typeof nested === 'object' || typeof nested === 'function')) {
queue.push({ value: nested, depth: depth + 1 });
}
}
} else {
queue.push({ value: child, depth: depth + 1 });
}
}
}
return uniqueRefs(found);
}
function summarizeCoWBuffProvider(provider) {
const valueRaw = Number(callSemantic(provider, 'get_buffValue', { optional: true }));
const element = String(callSemantic(provider, 'get_buffElement', { optional: true }) ?? '').trim();
const perk = callSemantic(provider, 'get_buffPerk', { optional: true }) ?? null;
const perkIdRaw = Number(callSemantic(provider, 'get_buffPerkId', { optional: true }));
const desc = callSemantic(provider, 'get_buffDesc', { optional: true }) ?? null;
const extended = stripHeroWarsColorMarkup(callSemantic(provider, 'get_extendedDescText', { optional: true }));
const translationKey = typeof desc?.S4b === 'string' ? desc.S4b : '';
const fallbackType = String(desc?.ng?.ng ?? desc?.Nx ?? '').trim();
const type = translationKey ? nativeText(translationKey, fallbackType) : fallbackType;
return {
provider,
value: Number.isFinite(valueRaw) ? valueRaw : null,
element,
perk,
perkId: Number.isFinite(perkIdRaw) ? perkIdRaw : null,
type,
description: extended,
};
}
function getCoWBuffReference(mediator, popup = null) {
const direct = uniqueRefs(
Object.values(mediator ?? {}).filter(value => className(value) === CLASS.cowAttackBuffVO)
);
const providers = uniqueRefs([
...direct,
...findObjectsByClassLimited([popup, mediator], CLASS.cowAttackBuffVO, { maxDepth: 7, maxNodes: 1600 }),
]);
if (!providers.length) return null;
const summaries = providers.map(summarizeCoWBuffProvider);
// Titan fortification buffs are element-based. Prefer those over a Hero/perk
// provider if both happen to be reachable from the same popup graph.
const elemental = summaries.filter(row => row.element);
const candidates = elemental.length ? elemental : summaries;
if (candidates.length > 1) {
const signature = row => JSON.stringify([
row.value,
row.element,
row.perkId,
row.type,
row.description,
]);
if (new Set(candidates.map(signature)).size !== 1) {
warn('COW_TITAN_BUFF_AMBIGUOUS', candidates.map(row => ({
value: row.value,
element: row.element,
perkId: row.perkId,
type: row.type,
})));
return null;
}
}
const { provider, ...result } = candidates[0];
return result;
}
function buildTitanReferenceTargetFromSlot(kind, slot, {
slotNumber = null,
listPosition = null,
building = '',
mediator = null,
popup = null,
} = {}) {
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 units = Array.isArray(team) ? team : [];
const titans = units.map(unit => {
const id = getTitanUnitId(unit);
return Number.isFinite(Number(id)) ? { id: Number(id), name: getTitanUnitName(unit, id) } : null;
}).filter(Boolean);
if (!titans.length) return null;
const resolvedSlotNumber = Number(slotNumber);
const resolvedListPosition = Number(listPosition);
return {
referenceType: 'titan',
kind,
slotNumber: Number.isFinite(resolvedSlotNumber)
? resolvedSlotNumber
: (Number.isFinite(resolvedListPosition) ? resolvedListPosition : null),
listPosition: Number.isFinite(resolvedListPosition) ? resolvedListPosition : null,
building: building || getSlotBuildingName(context, slot),
guild: getClanTitle(clan),
guildId: getClanId(clan),
playerId: getUserId(user),
playerName: getUserName(user),
titanIds: titans.map(row => row.id),
titanKey: sortedIdKey(titans.map(row => row.id)),
titanStateById: kind === 'CoW'
? getCoWTitanStateMap(slot, units)
: getGwTitanStateMap(slot, units),
currentTitanDefense: {
titans,
totems: getTitanSpiritsFromSlot(slot),
},
originalSlot: slot,
};
}
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 normalizeReplaySideUnits(replay, side) {
const root = side === 'attacker' ? replay?.attackers : replay?.defenders;
if (!root || typeof root !== 'object') return [];
const out = [];
const seen = new Set();
const visit = (value, depth = 0) => {
if (!value || typeof value !== 'object' || seen.has(value) || depth > 4) return;
seen.add(value);
if (String(value.type ?? '') === 'titan' && Number.isFinite(Number(value.id))) {
out.push(value);
return;
}
let children = [];
try { children = Array.isArray(value) ? value : Object.values(value); } catch { return; }
for (const child of children) visit(child, depth + 1);
};
visit(root, 0);
return out;
}
const BASE_TOTEM_ELEMENTS = new Set(['water', 'fire', 'earth']);
// Water/Fire/Earth summoners count as two Titans for Totem activation.
// Light/Dark still require two combat Titan units; their summoners do not
// reduce that requirement to a single slot.
const BASE_TOTEM_SUMMONER_IDS = new Set([4004, 4014, 4024]);
function getReplayActiveTotemElements(titanUnits) {
const unitCounts = new Map();
const weightedCounts = new Map();
for (const unit of titanUnits ?? []) {
const element = String(unit?.element ?? '').trim().toLowerCase();
if (!element) continue;
unitCounts.set(element, (unitCounts.get(element) ?? 0) + 1);
const id = Number(unit?.id);
const weight = BASE_TOTEM_ELEMENTS.has(element) && BASE_TOTEM_SUMMONER_IDS.has(id) ? 2 : 1;
weightedCounts.set(element, (weightedCounts.get(element) ?? 0) + weight);
}
const active = new Set();
for (const element of BASE_TOTEM_ELEMENTS) {
if ((weightedCounts.get(element) ?? 0) >= 3) active.add(element);
}
for (const element of ['light', 'dark']) {
if ((unitCounts.get(element) ?? 0) >= 2) active.add(element);
}
return active;
}
function summarizeReplayTitanTotems(titanUnits) {
// elementSpirit* on each replay Titan is Totem stat/ownership metadata. It is
// not proof that the Totem was active in this five-Titan lineup. First apply
// the game's composition activation rule, then use elementSpirit* only to
// describe the Totem for an element that can actually activate.
const activeElements = getReplayActiveTotemElements(titanUnits);
const groups = new Map();
for (const unit of titanUnits ?? []) {
const element = String(unit?.element ?? '').trim().toLowerCase();
if (!element || !activeElements.has(element)) continue;
const level = Number(unit?.elementSpiritLevel);
const star = Number(unit?.elementSpiritStar);
const power = Number(unit?.elementSpiritPower);
const skills = Array.isArray(unit?.elementSpiritSkills) ? unit.elementSpiritSkills : [];
// Eligibility alone is insufficient if the player did not own that Totem.
// ★0 is placeholder/no-owned-Totem metadata and must never be rendered.
if (!Number.isFinite(star) || star <= 0) continue;
const hasSpiritEvidence = (Number.isFinite(level) && level > 0)
|| (Number.isFinite(power) && power > 0)
|| skills.length > 0;
if (!hasSpiritEvidence) continue;
const existing = groups.get(element);
if (!existing || skills.length > (Array.isArray(existing.elementSpiritSkills) ? existing.elementSpiritSkills.length : 0)) {
groups.set(element, unit);
}
}
const rows = [];
for (const [element, unit] of groups.entries()) {
const desc = getTitanSpiritDescriptionInfoByElement(element);
const rawSkills = Array.isArray(unit?.elementSpiritSkills) ? unit.elementSpiritSkills.slice(0, 2) : [];
const skills = rawSkills.map((skill, index) => {
const id = Number(skill?.skillId ?? skill?.id);
const rank = Number(skill?.level ?? skill?.rank);
const info = getTitanSpiritSkillInfo(id);
return {
index: index + 1,
type: index === 0 ? 'Elemental' : 'Primal',
id: Number.isFinite(id) ? id : null,
rank: Number.isFinite(rank) ? rank : null,
name: info.name,
description: info.description,
};
});
const level = Number(unit?.elementSpiritLevel);
const star = Number(unit?.elementSpiritStar);
rows.push({
id: desc.id,
element,
name: desc.name,
level: Number.isFinite(level) ? level : null,
star: Number.isFinite(star) ? star : null,
skills,
});
}
return rows.sort((a, b) => a.element.localeCompare(b.element));
}
function summarizeReplayTitanSide(replay, side) {
const units = normalizeReplaySideUnits(replay, side);
const titans = units
.map(unit => {
const id = Number(unit?.id);
if (!Number.isFinite(id)) return null;
return {
id,
name: getTitanUnitName(unit, id),
element: String(unit?.element ?? '').trim(),
};
})
.filter(Boolean);
return {
titans,
titanKey: sortedIdKey(titans.map(row => row.id)),
totems: summarizeReplayTitanTotems(units),
};
}
function humanizeBattleBuffKey(keyValue) {
const raw = String(keyValue ?? '').trim();
if (!raw) return '';
const tail = raw.replace(/^percentBuffAll_/, '').replace(/^flatCurrentAndMaxHpBuff_/, '');
const spaced = tail
.replace(/_/g, ' ')
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.trim();
if (!spaced) return raw;
return spaced.replace(/\b\w/g, ch => ch.toUpperCase());
}
function containsPrimitiveString(root, needle, maxDepth = 4) {
if (!root || !needle) return false;
const seen = new Set();
const queue = [{ value: root, depth: 0 }];
let cursor = 0;
while (cursor < queue.length && cursor < 500) {
const { value, depth } = queue[cursor++];
if (value == null) continue;
if (typeof value === 'string') {
if (value === needle) return true;
continue;
}
if ((typeof value !== 'object' && typeof value !== 'function') || seen.has(value) || depth >= maxDepth) continue;
seen.add(value);
let children = [];
try { children = Object.values(value); } catch { continue; }
for (const child of children) queue.push({ value: child, depth: depth + 1 });
}
return false;
}
function findHistoricalBuffTranslationKey(buffSource, effectKey) {
if (!buffSource || !effectKey) return '';
const seen = new Set();
const queue = [{ value: buffSource, depth: 0 }];
let cursor = 0;
while (cursor < queue.length && cursor < 800) {
const { value, depth } = queue[cursor++];
if (!value || (typeof value !== 'object' && typeof value !== 'function') || seen.has(value)) continue;
seen.add(value);
const key = typeof value?.S4b === 'string' ? value.S4b : '';
if (key && containsPrimitiveString(value, effectKey, 4)) return key;
if (depth >= 6) continue;
let children = [];
try { children = Object.values(value); } catch { continue; }
for (const child of children) {
if (child && (typeof child === 'object' || typeof child === 'function')) queue.push({ value: child, depth: depth + 1 });
}
}
return '';
}
function summarizeHistoricalDefenderBuffs(replay, battleVo = null) {
const raw = replay?.effects?.defenders;
if (!raw || typeof raw !== 'object') return [];
const source = battleVo ? callSemantic(battleVo, 'get_defenderBuffs', { optional: true }) : null;
const rows = [];
for (const [effectKey, rawValue] of Object.entries(raw)) {
const value = Number(rawValue);
if (!Number.isFinite(value)) continue;
const translationKey = findHistoricalBuffTranslationKey(source, effectKey);
const fallback = humanizeBattleBuffKey(effectKey) || effectKey;
const name = translationKey ? nativeText(translationKey, fallback) : fallback;
const unit = /^percent/i.test(effectKey) ? '%' : '';
rows.push({ effectKey, value, name, unit });
}
return rows;
}
function getBattleAttacker(vo) {
return callSemantic(vo, 'get_attacker', { optional: true }) ?? null;
}
function getReplayAttackerResult(replay) {
const win = replay?.result?.win;
return typeof win === 'boolean' ? win : null;
}
function buildCowTitanBattleRecord(rawBattle, replayOverride = null) {
const BattleVO = findClass(CLASS.cowLogBattleVO);
const vo = new BattleVO(rawBattle);
const attackerUser = getBattleAttacker(vo);
const defenderUser = getBattleDefender(vo);
const replay = replayOverride ?? callSemantic(vo, 'get_replayRawData', { optional: true }) ?? rawBattle?.replay ?? rawBattle?.ov ?? null;
if (!replay) fail('COW_TITAN_REPLAY_MISSING');
const attacker = summarizeReplayTitanSide(replay, 'attacker');
const defender = summarizeReplayTitanSide(replay, 'defender');
const timestamp = getBattleTimestamp(rawBattle) || Number(replay?.startTime ?? 0) || 0;
return {
kind: 'CoW',
replayId: String(callSemantic(vo, 'get_replayId', { optional: true }) ?? replay?.id ?? ''),
timestamp,
dateString: formatBattleDate(callSemantic(vo, 'get_dateString', { optional: true }) ?? '', timestamp),
building: String(callSemantic(vo, 'get_position', { optional: true }) ?? ''),
position: String(callSemantic(vo, 'get_positionIndex', { optional: true }) ?? ''),
attacker: {
...attacker,
playerId: getUserId(attackerUser) || String(replay?.userId ?? ''),
playerName: getUserName(attackerUser),
},
defender: {
...defender,
playerId: getUserId(defenderUser) || String(replay?.typeId ?? ''),
playerName: getUserName(defenderUser),
},
attackerWon: getReplayAttackerResult(replay),
historicalBuffs: summarizeHistoricalDefenderBuffs(replay, vo),
};
}
function markTitanBattleMatches(record, titanKey) {
// Defense Reference is intentionally defender-only. Titan attack behavior can
// differ materially from defense even with the exact same five Titans, so an
// attacker-side lineup match is not useful evidence for the selected defense.
const defenderMatch = record?.defender?.titans?.length === 5
&& record.defender.titanKey === titanKey;
return { ...record, matchedSides: defenderMatch ? ['defender'] : [] };
}
function getAllCowHistoryBattles(history) {
const out = [];
for (const key of ['attack', 'defence', 'defense']) {
const rows = history?.[key];
if (!Array.isArray(rows)) continue;
for (const row of rows) out.push(row);
}
return out;
}
async function loadCowTitanPastReference(target, limit = 3) {
if (!target?.titanKey) fail('TITAN_PAST_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 BattleVO = findClass(CLASS.cowLogBattleVO);
const matches = [];
const replayIds = new Set();
const maxWars = 3;
const maxReplayLoads = 36;
let warsScanned = 0;
let titanCandidates = 0;
let replaysLoaded = 0;
for (const war of wars.slice(0, maxWars)) {
let history;
try {
const [season, warNo] = getHistoryArgs(war);
history = await commandList[methods.history](season, warNo, false);
} catch (error) {
warn('TITAN_PAST_COW_WAR_SKIPPED', error);
continue;
}
warsScanned += 1;
const candidates = [];
for (const rawBattle of getAllCowHistoryBattles(history)) {
try {
const vo = new BattleVO(rawBattle);
const isHero = callSemantic(vo, 'get_isHeroBattle', { optional: true });
if (isHero === true) continue;
const position = String(callSemantic(vo, 'get_position', { optional: true }) ?? '');
candidates.push({ rawBattle, sameBuilding: Boolean(target?.building) && position === String(target.building) });
} catch (error) {
warn('TITAN_PAST_COW_METADATA_SKIPPED', error);
}
}
candidates.sort((a, b) => {
if (a.sameBuilding !== b.sameBuilding) return a.sameBuilding ? -1 : 1;
return getBattleTimestamp(b.rawBattle) - getBattleTimestamp(a.rawBattle);
});
titanCandidates += candidates.length;
for (const candidate of candidates) {
if (replaysLoaded >= maxReplayLoads || matches.length >= limit) break;
const rawBattle = candidate.rawBattle;
try {
const vo = new BattleVO(rawBattle);
const replayId = String(callSemantic(vo, 'get_replayId', { optional: true }) ?? '');
if (replayId && replayIds.has(replayId)) continue;
let replay = callSemantic(vo, 'get_replayRawData', { optional: true }) ?? rawBattle?.replay ?? rawBattle?.ov ?? null;
if (!replay && replayId) replay = await loadBattleReplayForVo(vo);
if (!replay || String(replay?.type ?? '') !== 'clan_global_pvp_titan') continue;
replaysLoaded += 1;
if (replayId) replayIds.add(replayId);
const record = markTitanBattleMatches(buildCowTitanBattleRecord(rawBattle, replay), target.titanKey);
if (record.matchedSides.length) matches.push(record);
} catch (error) {
warn('TITAN_PAST_COW_REPLAY_SKIPPED', error);
}
}
if (matches.length >= limit || replaysLoaded >= maxReplayLoads) break;
}
matches.sort((a, b) => b.timestamp - a.timestamp);
return {
battles: matches.slice(0, limit),
stats: {
source: 'cow-real-battle-log',
warsAvailable: wars.length,
warsScanned,
titanCandidates,
replaysLoaded,
replayLoadLimit: maxReplayLoads,
},
};
}
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;
const recordPetIds = [];
for (const hero of record.heroes) {
if (Number(hero.patronPetId) > 0) recordPetIds.push(Number(hero.patronPetId));
}
if (!recordPetIds.length) continue;
for (const petId of recordPetIds) petIds.add(petId);
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,
};
}
function getGwTitanBattleEntries(warEntry) {
if (!warEntry || className(warEntry) !== CLASS.gwLogWarEntry) return [];
const out = [];
for (const semantic of ['get_attack', 'get_defence']) {
const rows = callSemantic(warEntry, semantic, { optional: true });
if (!Array.isArray(rows)) continue;
for (const entry of rows) {
if (!entry || className(entry) !== CLASS.gwLogBattleEntry) continue;
const replay = callSemantic(entry, 'get_replay', { optional: true });
if (!replay || String(replay?.type ?? '') !== 'clan_pvp_titan') continue;
out.push(entry);
}
}
return out;
}
function getGwParticipantUser(participant) {
return participant ? (callSemantic(participant, 'get_user', { optional: true }) ?? null) : null;
}
function getGwParticipantUserId(participant) {
if (!participant) return '';
const user = getGwParticipantUser(participant);
const fromUser = getUserId(user);
if (fromUser) return fromUser;
const value = callSemantic(participant, 'get_userId', { optional: true });
return value == null ? '' : String(value);
}
function buildGwTitanBattleRecord(entry, target = null) {
const attackerParticipant = callSemantic(entry, 'get_attacker', { optional: true });
const defenderParticipant = callSemantic(entry, 'get_defender', { optional: true });
const replay = callSemantic(entry, 'get_replay', { optional: true });
if (!replay) fail('GW_TITAN_REPLAY_MISSING');
const attacker = summarizeReplayTitanSide(replay, 'attacker');
const defender = summarizeReplayTitanSide(replay, 'defender');
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);
const rawSlotId = Number(callSemantic(entry, 'get_slotId', { optional: true }));
const slotId = Number.isFinite(rawSlotId) ? rawSlotId : null;
const location = slotId == null ? null : resolveGwHistoricalLocation(slotId, target);
const attackerUser = getGwParticipantUser(attackerParticipant);
const defenderUser = getGwParticipantUser(defenderParticipant);
return {
kind: 'GW',
replayId: String(replay?.id ?? ''),
timestamp,
dateString: formatBattleDate('', timestamp),
building: location?.building ?? 'GW slot',
position: location?.position ?? (slotId == null ? '?' : String(slotId)),
slotId,
attacker: {
...attacker,
playerId: getGwParticipantUserId(attackerParticipant) || String(replay?.userId ?? ''),
playerName: getUserName(attackerUser),
},
defender: {
...defender,
playerId: getGwParticipantUserId(defenderParticipant) || String(replay?.typeId ?? ''),
playerName: getUserName(defenderUser),
},
attackerWon: getReplayAttackerResult(replay),
historicalBuffs: [],
};
}
async function loadGwTitanPastReference(target, limit = 5) {
if (!target?.titanKey) fail('TITAN_PAST_TARGET_INCOMPLETE');
const commandList = getGwCommandList();
const methods = getGwCommandMethods(commandList);
const availableCommand = await awaitGwCommand(
() => commandList[methods.available](),
methods.completion,
'GW_TITAN_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 matches = [];
const replayIds = new Set();
let daysScanned = 0;
let titanCandidates = 0;
for (const logEntry of logs) {
const day = getGwLogDay(logEntry);
if (!day) continue;
let warEntry;
try {
const command = await awaitGwCommand(
() => commandList[methods.day](day, 0, null, null),
methods.completion,
'GW_TITAN_DAY_HISTORY'
);
warEntry = callSemantic(command, 'get_log', { optional: true });
} catch (error) {
warn('TITAN_PAST_GW_DAY_SKIPPED', error);
continue;
}
if (!warEntry) continue;
daysScanned += 1;
const entries = getGwTitanBattleEntries(warEntry).slice().sort((a, b) => {
const ta = Number(callSemantic(a, 'get_timestamp', { optional: true }) ?? 0);
const tb = Number(callSemantic(b, 'get_timestamp', { optional: true }) ?? 0);
return tb - ta;
});
titanCandidates += entries.length;
for (const entry of entries) {
try {
const replay = callSemantic(entry, 'get_replay', { optional: true });
const replayId = String(replay?.id ?? '');
if (replayId && replayIds.has(replayId)) continue;
if (replayId) replayIds.add(replayId);
const record = markTitanBattleMatches(buildGwTitanBattleRecord(entry, target), target.titanKey);
if (record.matchedSides.length) matches.push(record);
} catch (error) {
warn('TITAN_PAST_GW_BATTLE_SKIPPED', error);
}
}
if (matches.length >= limit) break;
}
matches.sort((a, b) => b.timestamp - a.timestamp);
return {
battles: matches.slice(0, limit),
stats: { source: 'gw-real-battle-log', daysAvailable: logs.length, daysScanned, titanCandidates },
};
}
async function loadTitanPastReference(target) {
if (target?.kind === 'CoW') return loadCowTitanPastReference(target, 3);
if (target?.kind === 'GW') return loadGwTitanPastReference(target, 3);
fail('TITAN_PAST_TARGET_KIND_UNSUPPORTED');
}
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 getPatternId(pattern) {
if (!pattern) return null;
try {
const id = Number(callSemantic(pattern, 'get_id', { optional: true }));
return Number.isFinite(id) ? id : null;
} catch {
return null;
}
}
function shortIdentityFallback(name, id, fallback = '?') {
const numericId = Number(id);
if (Number.isFinite(numericId) && numericId > 0) return `#${numericId}`;
const text = String(name || fallback || '?').trim() || '?';
return text.length <= 6 ? text : `${text.slice(0, 5)}…`;
}
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');
if (bannerVO === null) {
return {
bannerId: null,
flagName: '',
patterns: [],
patternsKnown: true,
patternSlotsUnknown: [],
};
}
if (!bannerVO) fail('CURRENT_BANNER_STATE_UNRESOLVED');
const entry = getCurrentBannerEntry(bannerVO);
const desc = entry ? callSemantic(entry, 'get_desc') : null;
if (!desc) fail('CURRENT_BANNER_DESC_NOT_FOUND');
const bannerIdRaw = Number(callSemantic(desc, 'get_id'));
if (!Number.isFinite(bannerIdRaw) || bannerIdRaw <= 0) fail('CURRENT_BANNER_ID_NOT_FOUND');
const bannerId = bannerIdRaw;
const flagName = String(
(desc ? callSemantic(desc, 'get_name', { optional: true }) : null) ?? desc?.ri ?? ''
);
let rows = [];
let patternSlotsUnknown = [];
try {
const map = getIntMapFromBannerEntry(entry);
const entries = getPatternEntries(map);
const validRows = [];
const invalidSlots = [];
for (const [patternSlot, pattern] of entries) {
if (!Number.isInteger(patternSlot) || patternSlot < 0 || patternSlot > 2) continue;
if (pattern?.__class__?.j === CLASS.bannerStoneDescription) {
validRows.push([patternSlot, pattern]);
} else if (pattern != null) {
invalidSlots.push(patternSlot);
}
}
rows = validRows.sort((a, b) => a[0] - b[0]);
patternSlotsUnknown = [...new Set(invalidSlots)].sort((a, b) => a - b);
} catch (error) {
warn('CURRENT_PATTERN_MAP_SKIPPED', error);
patternSlotsUnknown = [0, 1, 2];
}
// 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,
id: getPatternId(pattern),
name: getPatternName(pattern),
resourceKey,
currentValue: getPatternBuffValue(pattern),
colorTier,
ultimateLevel,
};
});
return {
bannerId,
flagName,
patterns,
patternsKnown: patternSlotsUnknown.length === 0,
patternSlotsUnknown,
};
}
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 = 46) {
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 getTitanNativeTextureDataUrl(entry, semanticName) {
const texture = callSemantic(entry, semanticName, { optional: true });
if (!texture || className(texture) !== CLASS.subTexture) return null;
const region = callSemantic(texture, 'get_region', { optional: true });
const frame = callSemantic(texture, 'get_frame', { optional: true });
const parent = callSemantic(texture, 'get_parent', { optional: true });
const rotated = callSemantic(texture, 'get_rotated', { optional: true });
const values = [region?.x, region?.y, region?.width, region?.height];
if (!region || values.some(value => !Number.isFinite(Number(value))) ||
Number(region.width) <= 0 || Number(region.height) <= 0 ||
rotated !== false || className(parent) !== CLASS.concreteTexture) {
return null;
}
if (frame) {
const frameValues = [frame.x, frame.y, frame.width, frame.height];
if (frameValues.some(value => !Number.isFinite(Number(value))) ||
Number(frame.width) <= 0 || Number(frame.height) <= 0) {
return null;
}
}
return cropSubTextureToDataUrl(texture);
}
async function getTitanFrameSpriteSpec(id) {
const numericId = Number(id);
if (!Number.isFinite(numericId) || numericId <= 0) return null;
if (titanFrameSpecCache.has(numericId)) return titanFrameSpecCache.get(numericId);
if (titanFrameSpecPromiseCache.has(numericId)) return titanFrameSpecPromiseCache.get(numericId);
const promise = Promise.resolve().then(() => {
try {
const description = getUnitDescription(numericId);
if (!description || className(description) !== CLASS.titanDescription) return null;
const TitanEntryVO = findClass(CLASS.titanEntryVO);
const entry = new TitanEntryVO(description, null);
const frameDataUrl = getTitanNativeTextureDataUrl(entry, 'get_qualityFrame');
const backgroundDataUrl = getTitanNativeTextureDataUrl(entry, 'get_qualityBackground');
if (!frameDataUrl || !backgroundDataUrl) return null;
const spec = Object.freeze({
frameDataUrl,
backgroundDataUrl,
});
titanFrameSpecCache.set(numericId, spec);
return spec;
} catch (error) {
warn(`TITAN_FRAME_ICON_FAILED_${numericId}`, error);
return null;
}
});
titanFrameSpecPromiseCache.set(numericId, promise);
try {
return await promise;
} finally {
titanFrameSpecPromiseCache.delete(numericId);
}
}
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 recordUnitIconDiagnostic(id, diagnostic) {
const row = { id: Number(id), ...diagnostic };
const previous = unitIconDiagnostics.get(row.id);
unitIconDiagnostics.set(row.id, row);
if (unitIconDiagnostics.size > 100) unitIconDiagnostics.delete(unitIconDiagnostics.keys().next().value);
if (JSON.stringify(previous) === JSON.stringify(row)) return;
if (row.stage === 'sprite_spec_ready' || row.stage === 'image_ready') log('UNIT_ICON_DIAGNOSTIC', row);
else warn('UNIT_ICON_DIAGNOSTIC', row);
}
function getNativeIconRenderMethod(asset, diagnostic) {
const Provider = findClass(CLASS.iconContentProvider);
const methods = prototypeMethodsDeep(Provider);
const shapeExpressions = new Set(['this.shape']);
const shapeGetter = findGetter(Provider, 'get_shape', { optional: true });
if (shapeGetter) {
shapeExpressions.add(`this.${shapeGetter}()`);
const getter = methods.find(row => row.name === shapeGetter);
const field = getter?.source.replace(/\s+/g, '').match(/^function\(\)\{returnthis\.([$\w]+);?\}$/)?.[1];
if (field) shapeExpressions.add(`this.${field}`);
}
const shapeSetter = findGetter(Provider, 'set_shape', { optional: true });
const setter = methods.find(row => row.name === shapeSetter)?.source.replace(/\s+/g, '');
const argument = setter?.match(/^function\(([$\w]+)\)/)?.[1];
if (argument) {
const fields = [...setter.matchAll(new RegExp(`this\\.([$\\w]+)=${argument.replace(/\$/g, '\\$')}(?=[,;}])`, 'g'))]
.map(match => match[1]);
if (new Set(fields).size === 1) shapeExpressions.add(`this.${fields[0]}`);
}
const lifecycle = getIconAssetLifecycle(asset);
const excluded = new Set(['dispose', ...Object.values(lifecycle ?? {})]);
const sources = new Map();
const shapeCalls = new Map();
for (const row of methods) {
// String/comment contents are not evidence of an executable call.
const source = row.source.replace(/"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`|\/\*[\s\S]*?\*\/|\/\/[^\r\n]*/g, ' ');
sources.set(row.name, source);
const matches = [];
const calls = source.matchAll(/\b((?:this\s*\.\s*)?[$\w]+(?:\s*\(\s*\))?)\s*\.\s*([$\w]+)\s*\(\s*(this\s*\.\s*[$\w]+(?:\s*\(\s*\))?)\s*\)/g);
for (const match of calls) {
const name = match[2];
const receiver = match[1].replace(/\s+/g, '');
const shapeExpression = match[3].replace(/\s+/g, '');
if (receiver === 'this' || !shapeExpressions.has(shapeExpression) || excluded.has(name)) continue;
const fn = asset[name];
if (typeof fn !== 'function' || fn.length > 1) continue;
matches.push({ method: name, consumer: row.name, receiver, shapeExpression });
}
shapeCalls.set(row.name, matches);
}
// The native entry checks readiness, uses a preview while incomplete, and
// registers a provider callback. Only that callback's shape call is final.
// All names below come from the live methods, including the ready wrapper.
const entries = [];
diagnostic.unresolvedCompletionEntries = [];
for (const [name, source] of sources) {
if (!lifecycle) break;
const compact = source.replace(/\s+/g, '');
const previews = shapeCalls.get(name).filter(call => compact.includes(`${call.receiver}.${lifecycle.ready}()`));
if (!previews.length) continue;
const callbacks = new Set();
// Haxe bind-helper / callback registration arguments; no helper name assumed.
for (const match of source.matchAll(/(?:\(|,)\s*this\s*\.\s*([$\w]+)\s*(?=[,)])/g)) {
if (sources.has(match[1]) && match[1] !== name) callbacks.add(match[1]);
}
for (const match of source.matchAll(/\bthis\s*\.\s*([$\w]+)\s*\.\s*bind\s*\(\s*this\s*\)/g)) {
if (sources.has(match[1]) && match[1] !== name) callbacks.add(match[1]);
}
if (!callbacks.size) continue;
// Prefer the callback shared by the already-ready branch and registration.
// Without that evidence, multiple registered callbacks are not interchangeable.
const readyCallbacks = new Set();
for (const preview of previews) {
const readyCall = `${preview.receiver}.${lifecycle.ready}()`.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const readyBranch = new RegExp(`(?:if\\(${readyCall}\\)\\{?(?:return)?|(?<![!$\\w.])${readyCall}(?:\\?|&&))this\\.([$\\w]+)\\(`, 'g');
for (const match of compact.matchAll(readyBranch)) {
if (callbacks.has(match[1])) readyCallbacks.add(match[1]);
}
}
if (readyCallbacks.size) {
entries.push({ name, previews, callbacks: [...readyCallbacks], evidence: 'ready_branch_and_registration' });
} else if (callbacks.size === 1) {
entries.push({ name, previews, callbacks: [...callbacks], evidence: 'single_registered_callback' });
} else {
diagnostic.unresolvedCompletionEntries.push({ entry: name, callbacks: [...callbacks] });
}
}
const previewMethods = new Set(entries.flatMap(entry => entry.previews.map(call => call.method)));
const entryNames = new Set(entries.map(entry => entry.name));
const candidates = new Map();
diagnostic.readyMethod = lifecycle?.ready ?? null;
diagnostic.previewCandidates = [...previewMethods];
diagnostic.completionRoutes = [];
for (const entry of entries) {
for (const callback of entry.callbacks) {
if (entryNames.has(callback)) continue;
const finals = shapeCalls.get(callback).filter(call => !previewMethods.has(call.method));
diagnostic.completionRoutes.push({ entry: entry.name, callback, evidence: entry.evidence, finalMethods: finals.map(call => call.method) });
for (const call of finals) candidates.set(call.method, { ...call, entryMethod: entry.name, completionCallback: callback });
}
}
diagnostic.candidates = [...candidates.values()];
if (!entries.length) {
diagnostic.stage = !lifecycle ? 'native_lifecycle_unresolved'
: diagnostic.unresolvedCompletionEntries.length ? 'native_completion_route_ambiguous' : 'native_completion_route_not_found';
return null;
}
if (candidates.size !== 1) {
diagnostic.stage = candidates.size ? 'native_final_render_ambiguous' : 'native_final_render_not_found';
return null;
}
const contract = [...candidates.values()][0];
diagnostic.renderMethod = contract.method;
diagnostic.consumerMethod = contract.consumer;
diagnostic.entryMethod = contract.entryMethod;
return contract.method;
}
function readIconTextureProperty(texture, semantic) {
let method = null;
try {
method = findGetter(texture?.__class__, semantic, { optional: true });
if (!method) return { status: 'missing', method: null };
if (typeof texture[method] !== 'function') return { status: 'not_callable', method };
return { status: 'ok', method, value: texture[method]() };
} catch (error) {
return { status: 'threw', method, errorCode: error?.code ?? error?.name ?? 'Error' };
}
}
function inspectIconTextureTransform(texture) {
const nodes = [];
const seen = new Map();
let current = texture;
let end = 'depth_limit';
let cycleTo = null;
// Follow only the semantic parent chain, never an arbitrary game object graph.
while (current && typeof current === 'object' && nodes.length < 8) {
if (seen.has(current)) {
end = 'cycle';
cycleTo = seen.get(current);
break;
}
seen.set(current, nodes.length);
const node = { value: current, reads: new Map() };
nodes.push(node);
if (className(current) !== CLASS.subTexture) {
end = className(current) ? 'root_reached' : 'parent_class_unresolved';
break;
}
const parent = readIconTextureProperty(current, 'get_parent');
node.reads.set('get_parent', parent);
if (parent.status !== 'ok') { end = 'parent_unresolved'; break; }
if (!parent.value || typeof parent.value !== 'object') { end = 'parent_invalid'; break; }
current = parent.value;
}
const scalar = value => {
if (value == null) return { type: value === null ? 'null' : 'undefined', value: null };
if (typeof value === 'number') return { type: 'number', value: Number.isFinite(value) ? value : String(value) };
if (typeof value === 'boolean') return { type: 'boolean', value };
return { type: typeof value, value: null };
};
const fields = (value, keys) => {
if (value == null) return null;
const values = {}, invalidFields = [];
for (const key of keys) {
let field;
try { field = value[key]; } catch { invalidFields.push(key); values[key] = null; continue; }
values[key] = scalar(field).value;
if (typeof field !== 'number' || !Number.isFinite(field)) invalidFields.push(key);
}
return { values, invalidFields };
};
const rectangles = ['region', 'frame'];
const matrices = ['transformationMatrix', 'transformationMatrixToRoot'];
const numbers = ['width', 'height', 'scale', 'nativeWidth', 'nativeHeight'];
const chain = nodes.map((node, depth) => {
const report = { depth, className: className(node.value), getters: {} };
const names = [...rectangles, 'rotated', ...numbers, ...matrices];
for (const name of names) {
const semantic = `get_${name}`;
// Derived getters may follow parents recursively. Do not call them on a
// broken/cyclic chain; retain the safe leaf geometry and the stop reason.
const read = end !== 'root_reached' && (numbers.includes(name) || matrices.includes(name))
? { status: 'skipped_incomplete_chain', method: null }
: readIconTextureProperty(node.value, semantic);
node.reads.set(semantic, read);
report.getters[name] = {
status: read.status, method: read.method,
...(read.status === 'ok' ? { valueType: read.value === null ? 'null' : typeof read.value } : {}),
...(read.errorCode ? { errorCode: read.errorCode } : {}),
};
if (read.status !== 'ok') { report[name] = null; continue; }
if (rectangles.includes(name)) report[name] = fields(read.value, ['x', 'y', 'width', 'height']);
else if (matrices.includes(name)) report[name] = fields(read.value, ['a', 'b', 'c', 'd', 'tx', 'ty']);
else report[name] = scalar(read.value);
}
const parent = node.reads.get('get_parent');
report.parentClass = parent?.status === 'ok' ? className(parent.value) : null;
if (parent) report.getters.parent = { status: parent.status, method: parent.method, ...(parent.errorCode ? { errorCode: parent.errorCode } : {}) };
return report;
});
const leaf = nodes[0]?.reads ?? new Map();
const rotated = leaf.get('get_rotated') ?? { status: 'missing', method: null };
const parent = leaf.get('get_parent') ?? { status: 'missing', method: null };
const rotatedTruthy = rotated.status === 'ok' ? Boolean(rotated.value) : null;
const parentIsSubTexture = parent.status === 'ok' ? className(parent.value) === CLASS.subTexture : null;
const reason = rotatedTruthy && parentIsSubTexture ? 'rotated_and_parent_subtexture'
: rotatedTruthy ? 'rotated' : parentIsSubTexture ? 'parent_subtexture' : null;
return {
rotated, parent,
region: leaf.get('get_region'), frame: leaf.get('get_frame'),
report: {
rotatedTruthy, rotated: scalar(rotated.value), parentIsSubTexture,
parentClass: parent.status === 'ok' ? className(parent.value) : null,
reason, chainEnd: end, cycleTo, chain,
},
};
}
function flattenIconTextureTransform(report, diagnostic) {
const chain = report.chain;
const detail = diagnostic.textureFlatten = {
stage: 'validating', coordinateSpace: 'root_pixels', chainLength: chain.length,
};
const reject = (stage, depth, reason) => {
diagnostic.stage = stage;
Object.assign(detail, { stage, depth, reason });
return null;
};
if (report.chainEnd !== 'root_reached' || chain.length < 2 || chain.length > 8) {
return reject('texture_parent_chain_invalid', null, report.chainEnd);
}
const rootDepth = chain.length - 1;
if (chain[rootDepth].className !== CLASS.concreteTexture) {
return reject('texture_root_invalid', rootDepth, 'concrete_texture_required');
}
// Only floating-point roundoff is tolerated, never a visible rotation/skew
// or an arbitrary crop adjustment. Matrix values below are numeric copies.
const near = (a, b) => Number.isFinite(a) && Number.isFinite(b) &&
Math.abs(a - b) <= 64 * Number.EPSILON * Math.max(1, Math.abs(a), Math.abs(b));
const snap = value => near(value, Math.round(value)) ? Math.round(value) : value;
const positive = value => Number.isFinite(value) && value > 0;
const inside = (start, length, limit) => positive(length) && positive(limit) &&
(start >= 0 || near(start, 0)) && (start + length <= limit || near(start + length, limit));
const numericFields = (node, name, keys) => {
const field = node[name];
return node.getters[name]?.status === 'ok' && field && !field.invalidFields.length &&
keys.every(key => Number.isFinite(field.values[key])) ? field.values : null;
};
const rectKeys = ['x', 'y', 'width', 'height'];
const matrixKeys = ['a', 'b', 'c', 'd', 'tx', 'ty'];
const sizes = [];
for (let depth = 0; depth < chain.length; depth++) {
const node = chain[depth], size = {};
for (const name of ['width', 'height', 'nativeWidth', 'nativeHeight', 'scale']) {
const field = node[name];
if (node.getters[name]?.status !== 'ok' || field?.type !== 'number' || !positive(field.value)) {
return reject('texture_dimensions_invalid', depth, name);
}
size[name] = field.value;
}
if (!near(size.width * size.scale, size.nativeWidth) || !near(size.height * size.scale, size.nativeHeight)) {
return reject('texture_dimensions_invalid', depth, 'scale_mismatch');
}
sizes.push(size);
}
const root = sizes[rootDepth];
if (!Number.isSafeInteger(root.nativeWidth) || !Number.isSafeInteger(root.nativeHeight)) {
return reject('texture_root_invalid', rootDepth, 'native_pixel_dimensions');
}
detail.rootSize = { width: root.nativeWidth, height: root.nativeHeight, scale: root.scale };
const matrices = [];
for (let depth = 0; depth < rootDepth; depth++) {
const node = chain[depth], size = sizes[depth], parent = sizes[depth + 1];
if (node.className !== CLASS.subTexture || node.getters.parent?.status !== 'ok' ||
node.parentClass !== chain[depth + 1].className) {
return reject('texture_parent_chain_invalid', depth, 'parent_unverified');
}
if (node.getters.rotated?.status !== 'ok' || node.rotated?.type !== 'boolean') {
return reject('texture_transform_unresolved', depth, 'rotation_unverified');
}
if (node.rotated.value) return reject('texture_transform_unsupported', depth, 'rotation');
const region = numericFields(node, 'region', rectKeys);
if (!region || !inside(region.x, region.width, parent.width) || !inside(region.y, region.height, parent.height)) {
return reject('texture_parent_region_invalid', depth, 'region_outside_parent');
}
if (node.getters.frame?.status !== 'ok' || node.getters.frame.valueType === 'undefined') {
return reject('texture_geometry_unresolved', depth, 'frame_unverified');
}
if (node.frame !== null) {
const frame = numericFields(node, 'frame', rectKeys);
if (!frame || !inside(-frame.x, size.width, frame.width) || !inside(-frame.y, size.height, frame.height)) {
return reject('frame_invalid', depth, 'frame_does_not_contain_texture');
}
}
const matrix = numericFields(node, 'transformationMatrix', matrixKeys);
if (!matrix) return reject('texture_matrix_invalid', depth, 'matrix_unverified');
if (matrix.b !== 0 || matrix.c !== 0) return reject('texture_transform_unsupported', depth, 'rotation_or_skew');
if (!positive(matrix.a) || !positive(matrix.d)) return reject('texture_matrix_invalid', depth, 'nonpositive_scale');
if (!near(matrix.a * parent.width, region.width) || !near(matrix.d * parent.height, region.height) ||
!near(matrix.tx * parent.width, region.x) || !near(matrix.ty * parent.height, region.y) ||
!near(region.width * parent.scale, size.nativeWidth) || !near(region.height * parent.scale, size.nativeHeight)) {
return reject('texture_matrix_geometry_mismatch', depth, 'parent_coordinates');
}
matrices.push(matrix);
}
const multiply = (parent, child) => ({
a: parent.a * child.a + parent.c * child.b,
b: parent.b * child.a + parent.d * child.b,
c: parent.a * child.c + parent.c * child.d,
d: parent.b * child.c + parent.d * child.d,
tx: parent.a * child.tx + parent.c * child.ty + parent.tx,
ty: parent.b * child.tx + parent.d * child.ty + parent.ty,
});
let composed = { a: 1, b: 0, c: 0, d: 1, tx: 0, ty: 0 };
// M(root-parent) * ... * M(leaf) maps the leaf's unit UV square to root UVs.
for (let depth = rootDepth - 1; depth >= 0; depth--) {
composed = multiply(composed, matrices[depth]);
if (!matrixKeys.every(key => Number.isFinite(composed[key])) || !positive(composed.a) || !positive(composed.d)) {
return reject('texture_matrix_invalid', depth, 'composition_invalid');
}
const node = chain[depth];
if (node.getters.transformationMatrixToRoot?.status !== 'missing') {
const nativeRoot = numericFields(node, 'transformationMatrixToRoot', matrixKeys);
if (!nativeRoot || !positive(nativeRoot.a) || !positive(nativeRoot.d)) {
return reject('texture_matrix_invalid', depth, 'native_root_matrix_unverified');
}
if (nativeRoot.b !== 0 || nativeRoot.c !== 0 || !matrixKeys.every(key => near(composed[key], nativeRoot[key]))) {
return reject('texture_matrix_geometry_mismatch', depth, 'native_root_matrix_mismatch');
}
}
}
detail.matrix = composed;
const left = snap(composed.tx * root.nativeWidth), top = snap(composed.ty * root.nativeHeight);
const right = snap((composed.tx + composed.a) * root.nativeWidth);
const bottom = snap((composed.ty + composed.d) * root.nativeHeight);
const region = { x: left, y: top, width: snap(right - left), height: snap(bottom - top) };
if (!rectKeys.every(key => Number.isFinite(region[key])) ||
!inside(region.x, region.width, root.nativeWidth) || !inside(region.y, region.height, root.nativeHeight)) {
return reject('texture_root_region_out_of_bounds', 0, 'root_crop');
}
if (!near(region.width, sizes[0].nativeWidth) || !near(region.height, sizes[0].nativeHeight)) {
return reject('texture_matrix_geometry_mismatch', 0, 'root_crop_size');
}
const leafFrame = chain[0].frame?.values;
const frame = leafFrame ? Object.fromEntries(rectKeys.map(key => [key, snap(leafFrame[key] * sizes[0].scale)])) : null;
if (frame && (!rectKeys.every(key => Number.isFinite(frame[key])) || !positive(frame.width) || !positive(frame.height))) {
return reject('frame_invalid', 0, 'root_pixel_frame');
}
Object.assign(detail, { stage: 'ready', region, frame });
return { region, frame };
}
function getValidatedIconTexture(rendered, diagnostic) {
delete diagnostic.textureFlatten;
const queue = [{ value: rendered, depth: 0 }];
const seen = new Set();
const textures = new Set();
while (queue.length && seen.size < 100) {
const { value, depth } = queue.shift();
if (!value || typeof value !== 'object' || seen.has(value)) continue;
seen.add(value);
if (className(value) === CLASS.subTexture) {
textures.add(value);
continue;
}
if (value instanceof Node || ArrayBuffer.isView(value) || value instanceof ArrayBuffer) continue;
// A semantic texture getter takes precedence over unrelated cached fields.
const texture = callSemantic(value, 'get_texture', { optional: true });
if (texture) {
queue.push({ value: texture, depth: depth + 1 });
continue;
}
if (depth >= 4) continue;
for (const descriptor of Object.values(Object.getOwnPropertyDescriptors(value))) {
if (descriptor.value && typeof descriptor.value === 'object') queue.push({ value: descriptor.value, depth: depth + 1 });
}
}
if (queue.length || textures.size !== 1) {
diagnostic.stage = queue.length ? 'texture_scan_limit' : textures.size ? 'subtexture_ambiguous' : 'subtexture_not_found';
return null;
}
const texture = [...textures][0];
diagnostic.textureClass = className(texture);
const transform = inspectIconTextureTransform(texture);
diagnostic.textureTransform = transform.report;
diagnostic.transformReason = transform.report.reason;
const valid = value => value && Object.values(value).every(Number.isFinite) && value.width > 0 && value.height > 0;
const region = transform.report.chain[0]?.region?.values ?? null;
const frame = transform.report.chain[0]?.frame?.values ?? null;
diagnostic.region = region;
diagnostic.frame = frame;
if (transform.region?.status !== 'ok' || transform.frame?.status !== 'ok') {
diagnostic.stage = 'texture_geometry_unresolved';
return null;
}
if (!valid(region) || region.x < 0 || region.y < 0) {
diagnostic.stage = 'region_invalid';
return null;
}
if (frame && !valid(frame)) {
diagnostic.stage = 'frame_invalid';
return null;
}
if (transform.rotated.status !== 'ok' || typeof transform.rotated.value !== 'boolean' ||
transform.parent.status !== 'ok' || !transform.parent.value || typeof transform.parent.value !== 'object') {
diagnostic.stage = 'texture_transform_unresolved';
return null;
}
if (transform.report.chainEnd !== 'root_reached') {
diagnostic.stage = 'texture_parent_chain_invalid';
return null;
}
const flattened = flattenIconTextureTransform(transform.report, diagnostic);
if (!flattened) return null;
diagnostic.region = flattened.region;
diagnostic.frame = flattened.frame;
return { texture, ...flattened };
}
function extractUnitIconSpriteSpec(asset, { nativeAtlasFallback = false, diagnostic = {} } = {}) {
diagnostic.stage = 'render_method';
diagnostic.route = 'legacy';
let renderMethod = null;
try {
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'
);
} catch (error) {
if (!nativeAtlasFallback || className(asset) !== CLASS.atlasTextureIconAsset) throw error;
diagnostic.route = 'native_consumer';
diagnostic.stage = 'native_contract';
renderMethod = getNativeIconRenderMethod(asset, diagnostic);
if (!renderMethod) return null;
const lifecycle = getIconAssetLifecycle(asset);
diagnostic.stage = 'asset_ready';
if (!lifecycle || !asset[lifecycle.ready]()) return null;
}
diagnostic.stage = 'render_call';
// Preserve HWCT's existing shape argument; only method discovery changes.
const rendered = asset[renderMethod](0);
try {
diagnostic.stage = 'texture';
const validated = diagnostic.route === 'native_consumer' ? getValidatedIconTexture(rendered, diagnostic) : null;
if (diagnostic.route === 'native_consumer' && !validated) return null;
const texture = validated?.texture ?? findSubTexture(rendered, { maxDepth: 4 });
if (!texture) return null;
const region = validated ? validated.region : callSemantic(texture, 'get_region', { optional: true });
const frame = validated ? validated.frame : callSemantic(texture, 'get_frame', { optional: true });
if (!region) return null;
diagnostic.stage = 'atlas_url';
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) {
diagnostic.urlRoute = 'generic_atlas';
url = getGenericAtlasInfo(asset)?.url ?? '';
} else {
diagnostic.urlRoute = 'atlas_imagefile';
}
} 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) {
diagnostic.stage = 'atlas_url_unresolved';
return null;
}
diagnostic.stage = 'sprite_spec_ready';
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 {
if (diagnostic.route !== 'native_consumer' ||
(rendered !== asset && className(rendered) !== CLASS.subTexture &&
!callSemantic(rendered, 'get_parent', { optional: true }))) 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 () => {
let diagnostic = null;
const recordDiagnostic = () => {
if (diagnostic?.route === 'native_consumer') recordUnitIconDiagnostic(numericId, diagnostic);
};
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;
const nativeAtlasFallback = className(asset) === CLASS.atlasTextureIconAsset;
diagnostic = { assetClass: className(asset) };
const extract = () => extractUnitIconSpriteSpec(asset, { nativeAtlasFallback, diagnostic });
// Fast path: assets already present in the current game state need no extra work.
try {
const immediate = extract();
if (immediate) {
unitIconSpecCache.set(numericId, immediate);
recordDiagnostic();
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) {
diagnostic.stage = 'asset_not_ready';
recordDiagnostic();
return null;
}
try {
const spec = extract();
recordDiagnostic();
if (!spec) return null;
unitIconSpecCache.set(numericId, spec);
return spec;
} finally {
prepared.release?.();
}
} catch (error) {
if (diagnostic) diagnostic.errorCode = error?.code ?? error?.name ?? 'Error';
recordDiagnostic();
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);
const diagnostic = unitIconDiagnostics.get(Number(id));
if (!imageSize?.width || !imageSize?.height) {
if (diagnostic?.route === 'native_consumer') {
recordUnitIconDiagnostic(id, { ...diagnostic, stage: 'image_load_failed' });
imageSizeCache.delete(spec.url);
unitIconSpecCache.delete(Number(id));
}
return null;
}
if (diagnostic?.route === 'native_consumer') {
if (spec.region.x + spec.region.width > imageSize.width || spec.region.y + spec.region.height > imageSize.height) {
recordUnitIconDiagnostic(id, { ...diagnostic, stage: 'atlas_region_out_of_bounds' });
unitIconSpecCache.delete(Number(id));
return null;
}
recordUnitIconDiagnostic(id, { ...diagnostic, stage: 'image_ready' });
}
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 getTitanReferenceIconSpec(iconAsset, diagnosticId) {
if (!iconAsset) return null;
try { return await getIconAssetSpriteSpec(iconAsset, diagnosticId); }
catch (error) {
warn(`TITAN_REFERENCE_ICON_FAILED_${diagnosticId}`, error);
return null;
}
}
function staticFunctionRows(ClassObject) {
const out = [];
for (const name of Object.getOwnPropertyNames(ClassObject ?? {})) {
let fn = null;
try { fn = ClassObject?.[name]; } catch { continue; }
if (typeof fn !== 'function') continue;
out.push({ name, fn, source: String(fn) });
}
return out;
}
function getTitanArtifactIconAsset(description) {
if (!description || className(description) !== CLASS.titanArtifactDescription) return null;
const AssetUtil = findClass(CLASS.assetStorageUtil, { optional: true });
if (!AssetUtil) return null;
const invoke = name => {
if (!name || typeof AssetUtil[name] !== 'function') return null;
try {
const asset = AssetUtil[name](description) ?? null;
return className(asset) === CLASS.rsxIconAsset ? asset : null;
} catch {
return null;
}
};
if (titanArtifactIconMapperMethodCache) {
const cached = invoke(titanArtifactIconMapperMethodCache);
if (cached) return cached;
titanArtifactIconMapperMethodCache = null;
}
// Harness v0.2.19 confirmed that the native generic description mapper is a
// static one-argument AssetStorageUtil function. Its current implementation
// starts with the description's explicit icon, then fans out through many
// description classes. Do not bind the minified method name; validate the
// result against a real TitanArtifactDescription instead.
const candidates = [];
for (const { name, fn, source } of staticFunctionRows(AssetUtil)) {
if (fn.length !== 1) continue;
if (!source.includes('.icon')) continue;
if ((source.match(/instanceof/g) ?? []).length < 4) continue;
const asset = invoke(name);
if (!asset) continue;
candidates.push({ name, asset });
}
const unique = [...new Map(candidates.map(row => [row.name, row])).values()];
if (unique.length !== 1) {
if (unique.length > 1) warn('TITAN_TOTEM_ICON_MAPPER_AMBIGUOUS', unique.map(row => row.name));
return null;
}
titanArtifactIconMapperMethodCache = unique[0].name;
return unique[0].asset;
}
async function getTitanTotemIconSpec(totem) {
const numericId = Number(totem?.id);
const cacheKey = Number.isFinite(numericId) && numericId > 0
? numericId
: String(totem?.element ?? 'unknown');
if (titanTotemIconSpecCache.has(cacheKey)) return titanTotemIconSpecCache.get(cacheKey);
if (titanTotemIconSpecPromiseCache.has(cacheKey)) return titanTotemIconSpecPromiseCache.get(cacheKey);
const promise = (async () => {
const description = totem?.iconDescription
?? getTitanSpiritDescriptionInfoByElement(totem?.element).description
?? null;
const asset = getTitanArtifactIconAsset(description);
const spec = await getTitanReferenceIconSpec(asset, `totem:${totem?.id ?? 'unknown'}`);
if (spec) titanTotemIconSpecCache.set(cacheKey, spec);
return spec;
})();
titanTotemIconSpecPromiseCache.set(cacheKey, promise);
try {
return await promise;
} finally {
titanTotemIconSpecPromiseCache.delete(cacheKey);
}
}
function getTitanSkillIconStorage() {
if (titanSkillIconStorageCache && className(titanSkillIconStorageCache) === CLASS.skillIconAssetStorage) {
return titanSkillIconStorageCache;
}
const AssetStorage = findClass(CLASS.assetStorage, { optional: true });
if (!AssetStorage) return null;
const matches = [];
try {
for (const key of Object.getOwnPropertyNames(AssetStorage)) {
let value = null;
try { value = AssetStorage[key]; } catch { continue; }
if (className(value) === CLASS.skillIconAssetStorage) matches.push(value);
}
} catch {}
const unique = uniqueRefs(matches);
if (unique.length !== 1) return null;
titanSkillIconStorageCache = unique[0];
return titanSkillIconStorageCache;
}
function getTitanSkillIconResolver(description) {
const storage = getTitanSkillIconStorage();
if (!storage || !description) return null;
const validate = resolver => {
if (!resolver?.method || !resolver?.identField) return null;
const ident = String(description?.[resolver.identField] ?? '').trim();
if (!ident) return null;
const fn = storage?.[resolver.method];
if (typeof fn !== 'function') return null;
try {
const asset = fn.call(storage, description) ?? null;
const assetClass = className(asset) ?? '';
if (!assetClass.endsWith('IconAsset')) return null;
return { ...resolver, ident, asset };
} catch {
return null;
}
};
if (titanSkillIconResolverCache) {
const cached = validate(titanSkillIconResolverCache);
if (cached) return cached;
titanSkillIconResolverCache = null;
}
// Resolve the inherited SkillIconAssetStorage method by source shape. The
// minified method and SkillDescription backing field names are deliberately
// not hard-coded. Current native code reads <textureIdent> and resolves
// `<textureIdent>.png` through the asset manager.
const candidates = [];
for (const { name, fn, source } of prototypeMethodsDeep(storage)) {
if (fn.length !== 1 || !source.includes('.png')) continue;
const compact = source.replace(/\s+/g, '');
const arg = compact.match(/^function\(([$\w]+)\)/)?.[1];
if (!arg) continue;
const escapedArg = arg.replace(/[$]/g, '\\$&');
const identMatch = new RegExp(`${escapedArg}\\.([A-Za-z_$][\\w$]*)\\+["']\\.png["']`).exec(compact);
if (!identMatch) continue;
if (!/new\s+[A-Za-z_$][\w$]*\(/.test(source)) continue;
const resolver = { method: name, identField: identMatch[1] };
const resolved = validate(resolver);
if (resolved) candidates.push(resolved);
}
const unique = [...new Map(candidates.map(row => [`${row.method}|${row.identField}`, row])).values()];
if (unique.length !== 1) {
if (unique.length > 1) warn('TITAN_SKILL_ICON_RESOLVER_AMBIGUOUS', unique.map(row => row.method));
return null;
}
titanSkillIconResolverCache = { method: unique[0].method, identField: unique[0].identField };
return unique[0];
}
function resourceUrlMatchesLogicalFile(urlValue, fileName) {
const target = String(fileName ?? '').trim();
const url = String(urlValue ?? '').trim();
if (!target || !url) return false;
let pathName = '';
try { pathName = new URL(url, location.href).pathname; } catch { pathName = url.split('?')[0]; }
let decoded = pathName;
try { decoded = decodeURIComponent(pathName); } catch {}
const base = decoded.split('/').pop() ?? '';
if (base === target) return true;
const dot = target.lastIndexOf('.');
if (dot <= 0) return false;
const stem = target.slice(0, dot);
const ext = target.slice(dot);
// Hero Wars often fingerprints resource filenames as <stem>.<hash><ext>.
return base.startsWith(`${stem}.`) && base.endsWith(ext);
}
function findLoadedResourceUrl(fileName) {
const target = String(fileName ?? '').trim();
if (!target || typeof performance === 'undefined' || typeof performance.getEntriesByType !== 'function') return '';
let entries = [];
try { entries = performance.getEntriesByType('resource') ?? []; } catch { return ''; }
const hits = [];
for (const entry of entries) {
const url = String(entry?.name ?? '');
if (resourceUrlMatchesLogicalFile(url, target)) hits.push(url);
}
const unique = [...new Set(hits)];
return unique.length === 1 ? unique[0] : '';
}
async function getStandaloneImageSpec(url) {
if (!url) return null;
const size = await getImageSize(url);
if (!size?.width || !size?.height) return null;
return {
url,
region: { x: 0, y: 0, width: size.width, height: size.height },
frame: null,
};
}
async function getImageIconAssetStandaloneSpec(asset, logicalFile) {
if (!asset || className(asset) !== 'game.assets.ImageIconAsset') return null;
// Harness v0.2.20 captured the native Fusion Skill route directly:
// SkillDescription -> SkillIconAssetStorage -> ImageIconAsset -> get_image()
// -> ImageFile. Use that exact native file instead of trying to extract the
// preview SubTexture, which is a generic placeholder in the current client.
const imageFile = callSemantic(asset, 'get_image', { optional: true })
?? findObjectByClassLimited(
asset,
'engine.core.assets.file.ImageFile',
{ maxDepth: 4, maxNodes: 60 }
)
?? null;
let url = getImageFileUrl(imageFile);
if (url && logicalFile && !resourceUrlMatchesLogicalFile(url, logicalFile)) {
url = '';
}
// If get_url is not exposed yet, use only the exact already-loaded logical
// file from ResourceTiming. Never synthesize a CDN URL.
if (!url) url = findLoadedResourceUrl(logicalFile);
return url ? await getStandaloneImageSpec(url) : null;
}
async function getTitanSkillIconSpec(skill) {
const numericId = Number(skill?.id);
const cacheKey = Number.isFinite(numericId) && numericId > 0
? numericId
: String(skill?.name ?? 'unknown');
if (titanSkillIconSpecCache.has(cacheKey)) return titanSkillIconSpecCache.get(cacheKey);
if (titanSkillIconSpecPromiseCache.has(cacheKey)) return titanSkillIconSpecPromiseCache.get(cacheKey);
const promise = (async () => {
const description = skill?.iconDescription ?? null;
const resolved = getTitanSkillIconResolver(description);
if (!resolved) return null;
const logicalFile = `${resolved.ident}.png`;
// Fast/current-client path confirmed by Harness v0.2.20.
const imageSpec = await getImageIconAssetStandaloneSpec(resolved.asset, logicalFile);
if (imageSpec) {
titanSkillIconSpecCache.set(cacheKey, imageSpec);
return imageSpec;
}
// Compatibility path for other IconAsset implementations.
const nativeSpec = await getTitanReferenceIconSpec(resolved.asset, `skill:${skill?.id ?? 'unknown'}`);
if (nativeSpec && resourceUrlMatchesLogicalFile(nativeSpec.url, logicalFile)) {
titanSkillIconSpecCache.set(cacheKey, nativeSpec);
return nativeSpec;
}
const url = findLoadedResourceUrl(logicalFile);
const fallback = url ? await getStandaloneImageSpec(url) : null;
if (fallback) titanSkillIconSpecCache.set(cacheKey, fallback);
return fallback;
})();
titanSkillIconSpecPromiseCache.set(cacheKey, promise);
try {
return await promise;
} finally {
titanSkillIconSpecPromiseCache.delete(cacheKey);
}
}
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; min-width:0; 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-width:0; 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; }
.version-hover { position:relative; display:inline-flex; align-items:center; gap:5px; color:inherit; font-weight:400; cursor:default; }
.version-hover.update-available { cursor:help; }
.version { color:#c9c9c9; font-weight:400; }
.version-hover { position:relative; display:inline-flex; align-items:center; gap:5px; font-weight:400; cursor:default; }
.version-hover.update-available { cursor:help; }
.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; }
[hidden] { display:none !important; }
.update-indicator { flex:0 0 auto; display:inline-flex; align-items:center; justify-content:center; width:12px; height:12px; border:0; padding:0; background:transparent; }
.update-dot { width:8px; height:8px; border-radius:50%; background:#f04455; border:1px solid #ffd6dc; box-shadow:0 0 5px rgba(240,68,85,.85); }
.update-tip { position:fixed; left:8px; top:8px; z-index:2147483647; width:min(260px, calc(100vw - 16px)); padding:8px 9px; border:1px solid rgba(255,255,255,.2); border-radius:7px; background:rgb(16,18,23); color:#eee; font-weight:400; font-size:12px; line-height:1.42; white-space:pre-line; box-shadow:0 5px 17px rgba(0,0,0,.55); pointer-events:none; display:none; }
.version-hover.update-available:hover .update-tip, .version-hover.update-available:focus .update-tip { display:block; }
.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 { min-width:0; padding:6px 5px 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; flex-wrap:nowrap; align-items:center; gap:calc(5px * var(--hwct-icon-scale, 1)); min-width:0; max-width:100%; min-height:calc(46px * var(--hwct-icon-scale, 1)); contain:inline-size; }
.iconbox { width:calc(46px * var(--hwct-icon-scale, 1)); height:calc(46px * 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; }
.titan-native-background, .titan-portrait-sprite, .titan-native-frame { position:absolute; display:block; pointer-events:none; transform:scale(var(--hwct-icon-scale, 1)); transform-origin:top left; }
.titan-native-background, .titan-portrait-sprite { left:calc(${TITAN_PORTRAIT_DISPLAY_INSET}px * var(--hwct-icon-scale, 1)); top:calc(${TITAN_PORTRAIT_DISPLAY_INSET}px * var(--hwct-icon-scale, 1)); width:${TITAN_PORTRAIT_DISPLAY_SIZE}px; height:${TITAN_PORTRAIT_DISPLAY_SIZE}px; }
.titan-native-background { z-index:0; background-repeat:no-repeat; background-position:center; background-size:100% 100%; }
.titan-portrait-sprite { z-index:1; }
.titan-native-frame { left:0; top:0; z-index:2; width:${TITAN_FRAME_DISPLAY_SIZE}px; height:${TITAN_FRAME_DISPLAY_SIZE}px; background-repeat:no-repeat; background-position:center; background-size:100% 100%; }
.iconbox.titan-native .placeholder { position:relative; z-index:1; }
.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; transform:scale(var(--hwct-icon-scale, 1)); transform-origin:center center; }
.placeholder { color:#aeb5c0; font:700 .82em Arial; text-align:center; padding:2px; }
.used-head { display:flex; align-items:center; gap:4px; min-width:0; margin-bottom:4px; }
.used-head .label { flex:1; margin:0; }
.label-line { display:flex; align-items:center; gap:5px; min-width:0; max-width:100%; 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; }
.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.tip-open, .info:hover, .info:focus, .info:focus-within { z-index:60; }
.info:hover .info-tip, .info:focus-within .info-tip, .info:focus .info-tip, .info.tip-open .info-tip { display:block; }
.last { display:block; max-width:100%; min-width:0; color:#bdbdbd; white-space:normal; overflow-wrap:anywhere; font-size:.92em; font-weight:400; line-height:1.25; }
.last-row { margin-top:4px; }
.current-row { display:flex; align-items:flex-start; justify-content:flex-start; gap:calc(5px * var(--hwct-icon-scale, 1)); min-width:0; max-width:100%; contain:inline-size; }
.current-inline-message { max-width:100%; white-space:normal; overflow-wrap:anywhere; }
.current-cell { width:calc(46px * 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(46px * var(--hwct-icon-scale, 1)); height:calc(46px * 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(46px * var(--hwct-icon-scale, 1)); height:calc(46px * var(--hwct-icon-scale, 1)); object-fit:contain; }
.current-icon.empty-pattern-slot .placeholder { max-width:100%; color:#e8e8e8; font-weight:400; font-size:.72em; line-height:1.05; overflow-wrap:anywhere; }
.current-icon.unknown-equipment .placeholder { color:#f1d38d; font-size:1.1em; }
.current-icon .known-fallback { max-width:100%; color:#f0f0f0; font-size:.68em; line-height:1.05; overflow-wrap:anywhere; }
.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; }
.totem-card { margin-top:5px; padding:6px 8px; border:1px solid rgba(255,255,255,.12); border-radius:7px; background:rgba(255,255,255,.035); }
.totem-inline-empty { margin-top:6px; color:#d7d7d7; font-weight:700; }
.totem-current-row { display:flex; align-items:flex-start; justify-content:flex-start; gap:calc(5px * var(--hwct-icon-scale, 1)); min-width:0; max-width:100%; contain:inline-size; }
.totem-main-group { display:flex; align-items:center; flex:0 0 auto; gap:calc(5px * var(--hwct-icon-scale, 1)); min-width:0; max-width:100%; }
.totem-meta-side { min-width:calc(38px * var(--hwct-icon-scale, 1)); display:flex; flex-direction:column; align-items:flex-start; justify-content:center; gap:calc(2px * var(--hwct-icon-scale, 1)); color:#e8e8e8; font-weight:700; font-size:.86em; line-height:1.05; white-space:nowrap; }
.totem-skill-cell { width:calc(46px * 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)); color:#e7e7e7; }
.totem-icon,
.totem-skill-icon { width:calc(46px * var(--hwct-icon-scale, 1)); height:calc(46px * var(--hwct-icon-scale, 1)); flex:0 0 calc(46px * var(--hwct-icon-scale, 1)); border-radius:calc(8px * var(--hwct-icon-scale, 1)); overflow:hidden; display:flex; align-items:center; justify-content:center; background:rgba(255,255,255,.025); cursor:default; box-sizing:border-box; }
.totem-native-sprite, .totem-skill-native-sprite { transform:scale(var(--hwct-icon-scale, 1)); transform-origin:center center; }
.totem-skill-rank { min-height:16px; color:#e8e8e8; font-weight:700; font-size:.86em; text-align:center; white-space:nowrap; }
.totem-icon-fallback { color:#bdbdbd; font-size:.72em; font-weight:700; text-align:center; line-height:1.1; }
.buff-main { font-weight:800; color:#f0f0f0; overflow-wrap:anywhere; }
.past-head { display:flex; align-items:center; gap:4px; margin-bottom:3px; }
.past-head .label-line { margin:0; }
.hero-defeated .hero-sprite, .hero-defeated .titan-portrait-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; }
.titan-past-head { display:flex; align-items:center; gap:5px; margin-bottom:5px; }
.titan-past-head .label { margin:0; }
.titan-past-count { margin-left:auto; color:#bdbdbd; font-size:.90em; white-space:nowrap; }
.titan-past-card { margin-top:7px; padding:7px; border:1px solid rgba(255,255,255,.13); border-radius:8px; background:rgba(255,255,255,.028); }
.titan-past-card:first-of-type { margin-top:4px; }
.titan-battle-head { display:flex; align-items:center; gap:6px; margin-bottom:5px; min-width:0; }
.titan-battle-date { font-weight:800; white-space:nowrap; }
.titan-battle-place { min-width:0; flex:1; color:#c8c8c8; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.titan-battle-result { flex:0 0 auto; padding:2px 5px; border-radius:4px; background:rgba(255,255,255,.09); font-weight:800; font-size:.84em; }
.titan-side { margin-top:5px; padding:6px; border-radius:6px; background:rgba(255,255,255,.03); border:1px solid rgba(255,255,255,.08); }
.titan-side.match { border-color:rgba(227,182,95,.72); background:rgba(227,182,95,.07); }
.titan-side-head { display:flex; align-items:center; gap:6px; margin-bottom:4px; }
.titan-side-title { font-weight:800; }
.titan-side-player { min-width:0; flex:1; color:#c8c8c8; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.titan-match-badge { flex:0 0 auto; padding:1px 5px; border-radius:4px; background:rgba(227,182,95,.20); color:#f3ce85; font-weight:900; font-size:.78em; }
.titan-side .icons { min-height:calc(46px * var(--hwct-icon-scale, 1)); gap:calc(5px * var(--hwct-icon-scale, 1)); }
.titan-side .iconbox.hero { width:calc(46px * var(--hwct-icon-scale, 1)); height:calc(46px * var(--hwct-icon-scale, 1)); }
.titan-past-totems { margin-top:4px; display:flex; flex-direction:column; gap:3px; }
.titan-past-totem { padding:4px 6px; border-radius:5px; background:rgba(0,0,0,.14); font-size:.90em; line-height:1.35; overflow-wrap:anywhere; }
.titan-past-totem strong { color:#fff; }
.titan-buff { margin-top:5px; padding:5px 6px; border-radius:5px; background:rgba(111,164,235,.08); color:#dceaff; font-size:.90em; overflow-wrap:anywhere; }
.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')} <span id="ref-version-hover" class="version-hover" tabindex="-1"><span class="version">· v${VERSION}</span><span id="ref-update" class="update-indicator" hidden aria-hidden="true"><span class="update-dot"></span></span><span id="ref-update-tip" class="update-tip"></span></span></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>
<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');
const refVersionHover = shadow.getElementById('ref-version-hover');
const refUpdate = shadow.getElementById('ref-update');
const refUpdateTip = shadow.getElementById('ref-update-tip');
panel.dataset.defeatedStyle = defeatedDisplayStyle;
let referenceUserMoved = Number.isFinite(uiState.refPanelX) && Number.isFinite(uiState.refPanelY);
let currentTargetKind = null;
let renderSequence = 0;
function getReferenceTechnicalMinWidth() {
try {
const headStyle = getComputedStyle(head);
const padding = Number.parseFloat(headStyle.paddingLeft || '0') + Number.parseFloat(headStyle.paddingRight || '0');
const toolMarkWidth = shadow.querySelector('.tool-mark')?.getBoundingClientRect?.().width ?? 0;
const controlsWidth = [...shadow.querySelectorAll('.head-action')]
.filter(button => !button.hidden)
.reduce((sum, button) => sum + (button.getBoundingClientRect?.().width || 0), 0);
const dragReserve = 14;
const measured = Math.ceil(padding + toolMarkWidth + controlsWidth + dragReserve);
return Math.max(REF_PANEL_TECHNICAL_MIN_WIDTH_FALLBACK, measured);
} catch {
return REF_PANEL_TECHNICAL_MIN_WIDTH_FALLBACK;
}
}
panel.style.width = `${getResponsivePanelWidth(uiState.refPanelWidth, REF_PANEL_DEFAULT_WIDTH, getReferenceTechnicalMinWidth)}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) {
// Follow the panel width much more closely than before. The old 0.92
// lower clamp kept icons near full size even when the panel was narrow.
// Allow scaling down to 50%, then keep the content scrollable if the
// user narrows the Reference panel further than the scaled content fits.
const scale = clamp(Number(width) / REF_PANEL_DEFAULT_WIDTH, 0.50, 2.00);
host.style.setProperty('--hwct-icon-scale', String(scale));
host.style.setProperty('--hwct-totem-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 positionUpdateTip(indicator, tip) {
if (!indicator || !tip || indicator.hidden) return;
const anchorRect = indicator.getBoundingClientRect();
const margin = 8;
const gap = 6;
const previousDisplay = tip.style.display;
const previousVisibility = tip.style.visibility;
tip.style.visibility = 'hidden';
tip.style.display = 'block';
const tipRect = tip.getBoundingClientRect();
let left = anchorRect.left;
let top = anchorRect.bottom + gap;
if (top + tipRect.height > window.innerHeight - margin) top = anchorRect.top - tipRect.height - gap;
left = clamp(left, margin, Math.max(margin, window.innerWidth - tipRect.width - margin));
top = clamp(top, margin, Math.max(margin, window.innerHeight - tipRect.height - margin));
tip.style.left = `${Math.round(left)}px`;
tip.style.top = `${Math.round(top)}px`;
tip.style.visibility = previousVisibility;
tip.style.display = previousDisplay;
}
const unsubscribeRefUpdate = subscribeUpdateAvailability(state => {
const show = Boolean(state.updateAvailable && state.latestVersion);
refVersionHover.classList.toggle('update-available', show);
refVersionHover.tabIndex = show ? 0 : -1;
refVersionHover.setAttribute('aria-label', show ? t('updateAvailable') : '');
refUpdate.hidden = !show;
refUpdateTip.textContent = show ? updateTooltipText(state.latestVersion) : '';
if (show && (refVersionHover.matches(':hover') || refVersionHover.matches(':focus'))) {
requestAnimationFrame(() => positionUpdateTip(refVersionHover, refUpdateTip));
}
});
for (const eventName of ['pointerenter', 'focus']) {
refVersionHover.addEventListener(eventName, () => {
if (!refVersionHover.classList.contains('update-available')) return;
requestAnimationFrame(() => positionUpdateTip(refVersionHover, refUpdateTip));
});
}
function setReferenceTarget(target) {
currentTargetKind = target?.kind ?? null;
titleText.childNodes[0].nodeValue = `${t('defenseReference')} `;
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, getReferenceTechnicalMinWidth);
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: getReferenceTechnicalMinWidth, 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();
});
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 = 46;
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) : '−';
// Keep the fixed icon footprint and overlays; expose the full name on hover.
if (sizeClass === 'hero' && unitIconDiagnostics.has(numericId)) {
try {
const description = getUnitDescription(numericId);
const name = callSemantic(description, 'get_name', { optional: true });
if (name) box.title = `${name} (#${numericId})${title ? ` - ${title}` : ''}`;
} catch {}
}
box.appendChild(p);
}
parent.appendChild(box);
return box;
}
async function addTitanUnitBox(parent, id, title) {
const numericId = Number(id);
const box = document.createElement('div');
box.className = 'iconbox hero';
box.title = title || String(id ?? '');
const frameSpec = Number.isFinite(numericId) && numericId > 0
? await getTitanFrameSpriteSpec(numericId)
: null;
const portraitSize = frameSpec ? TITAN_PORTRAIT_DISPLAY_SIZE : TITAN_FRAME_DISPLAY_SIZE;
const sprite = Number.isFinite(numericId) && numericId > 0
? await createUnitSprite(
numericId,
portraitSize,
frameSpec ? 'titan-portrait-sprite' : 'hero-sprite',
title
)
: null;
if (frameSpec) {
box.classList.add('titan-native');
if (frameSpec.backgroundDataUrl) {
const background = document.createElement('span');
background.className = 'titan-native-background';
background.style.backgroundImage = `url("${frameSpec.backgroundDataUrl}")`;
box.appendChild(background);
}
}
if (sprite) {
box.appendChild(sprite);
} else {
const placeholder = document.createElement('span');
placeholder.className = 'placeholder';
placeholder.textContent = Number.isFinite(numericId) && numericId > 0 ? String(numericId) : '−';
if (unitIconDiagnostics.has(numericId)) {
try {
const description = getUnitDescription(numericId);
const name = callSemantic(description, 'get_name', { optional: true });
if (name) box.title = `${name} (#${numericId})${title ? ` - ${title}` : ''}`;
} catch {}
}
box.appendChild(placeholder);
}
if (frameSpec) {
const frame = document.createElement('span');
frame.className = 'titan-native-frame';
frame.style.backgroundImage = `url("${frameSpec.frameDataUrl}")`;
box.appendChild(frame);
}
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);
if (target?.referenceType === 'titan') {
const defense = target.currentTitanDefense ?? { titans: [], totems: [] };
await Promise.allSettled((defense.titans ?? []).map(row => getUnitIconSpriteSpec(row.id)));
if (renderId !== renderSequence || !host.isConnected) return;
content.innerHTML = '';
const titanSection = document.createElement('div');
titanSection.className = 'section';
const titanLabel = document.createElement('div');
titanLabel.className = 'label';
titanLabel.textContent = t('currentDefense');
titanSection.appendChild(titanLabel);
const titanIcons = document.createElement('div');
titanIcons.className = 'icons';
for (const titan of defense.titans ?? []) {
const state = target?.titanStateById instanceof Map
? target.titanStateById.get(Number(titan.id))
: null;
const defeated = state?.alive === false;
const titanName = titan.name || `Titan ${titan.id}`;
const box = await addTitanUnitBox(
titanIcons,
titan.id,
defeated ? `${titanName} · ${t('defeatedUpper')}` : titanName
);
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);
}
}
titanSection.appendChild(titanIcons);
const totems = defense.totems ?? [];
const deferredTitanIconTasks = [];
if (!totems.length) {
const empty = document.createElement('div');
empty.className = 'totem-inline-empty';
empty.textContent = t('noTotem');
titanSection.appendChild(empty);
} else {
for (const totem of totems) {
const card = document.createElement('div');
card.className = 'totem-card';
const row = document.createElement('div');
row.className = 'totem-current-row';
// Totem keeps its current metadata to the right of the icon.
const totemGroup = document.createElement('div');
totemGroup.className = 'totem-main-group';
const totemIcon = document.createElement('div');
totemIcon.className = 'totem-icon';
const totemName = totem.name || `Totem ${totem.id}`;
totemIcon.title = totemName;
totemIcon.setAttribute('aria-label', totemName);
const totemMeta = document.createElement('div');
totemMeta.className = 'totem-meta-side';
if (totem.level != null) {
const levelLine = document.createElement('div');
levelLine.textContent = t('levelShort', { level: totem.level });
totemMeta.appendChild(levelLine);
}
if (totem.star != null) {
const starLine = document.createElement('div');
starLine.textContent = `★${totem.star}`;
totemMeta.appendChild(starLine);
}
totemGroup.append(totemIcon, totemMeta);
row.appendChild(totemGroup);
deferredTitanIconTasks.push(async () => {
const totemSpec = await getTitanTotemIconSpec(totem);
if (renderId !== renderSequence || !host.isConnected || !card.isConnected) return;
const totemSprite = totemSpec
? await createSpriteFromSpec(totemSpec, 46, 'totem-native-sprite', totemName)
: null;
if (renderId !== renderSequence || !host.isConnected || !card.isConnected) return;
applyTitanTierFrame(totemIcon, getTitanTotemTierByLevel(totem.level), 46);
if (totemSprite) {
totemIcon.replaceChildren(totemSprite);
} else {
const fallback = document.createElement('span');
fallback.className = 'totem-icon-fallback';
fallback.textContent = String(totem.id ?? '−');
totemIcon.replaceChildren(fallback);
}
});
// Fusion Skills follow Hero Current's icon + value-under-icon pattern.
for (const skill of (totem.skills ?? []).slice(0, 2)) {
const skillCell = document.createElement('div');
skillCell.className = 'totem-skill-cell';
const skillIcon = document.createElement('div');
skillIcon.className = 'totem-skill-icon';
const skillName = skill.name || (skill.id != null ? `#${skill.id}` : '−');
skillIcon.title = skillName;
skillIcon.setAttribute('aria-label', skillName);
const rankMeta = document.createElement('div');
rankMeta.className = 'totem-skill-rank';
rankMeta.textContent = skill.rank != null ? formatTitanRankShort(skill.rank) : '';
skillCell.append(skillIcon, rankMeta);
row.appendChild(skillCell);
deferredTitanIconTasks.push(async () => {
const skillSpec = await getTitanSkillIconSpec(skill);
if (renderId !== renderSequence || !host.isConnected || !skillCell.isConnected) return;
const skillSprite = skillSpec
? await createSpriteFromSpec(skillSpec, 46, 'totem-skill-native-sprite', skillName)
: null;
if (renderId !== renderSequence || !host.isConnected || !skillCell.isConnected) return;
applyTitanTierFrame(skillIcon, getTitanSkillTierByRank(skill.rank), 46);
if (skillSprite) {
skillIcon.replaceChildren(skillSprite);
} else {
const fallback = document.createElement('span');
fallback.className = 'totem-icon-fallback';
fallback.textContent = skill.id != null ? `#${skill.id}` : '−';
skillIcon.replaceChildren(fallback);
}
});
}
card.appendChild(row);
titanSection.appendChild(card);
}
}
content.appendChild(titanSection);
// Let the browser paint the Current reference first. Hydrate all optional
// images on the next frame; never block Totem name/Lv/★/skill text on icons.
requestAnimationFrame(() => {
if (renderId !== renderSequence || !host.isConnected) return;
for (const task of deferredTitanIconTasks) {
Promise.resolve()
.then(task)
.catch(error => warn('TITAN_DEFERRED_ICON_RENDER_FAILED', error));
}
});
if (!TITAN_PAST_REFERENCE_ENABLED) {
requestAnimationFrame(placeReferenceNearGamePopup);
return;
}
const pastSection = document.createElement('div');
pastSection.className = 'section';
const pastHead = document.createElement('div');
pastHead.className = 'titan-past-head';
const pastLabel = document.createElement('div');
pastLabel.className = 'label';
pastLabel.textContent = t('pastBattles');
const pastInfo = document.createElement('button');
pastInfo.type = 'button';
pastInfo.className = 'info';
pastInfo.setAttribute('aria-label', t('pastBattlesInfo'));
pastInfo.textContent = 'ⓘ';
const pastTip = document.createElement('span');
pastTip.className = 'info-tip';
pastTip.textContent = t('pastBattlesTip');
pastInfo.appendChild(pastTip);
bindInfoTip(pastInfo);
pastHead.append(pastLabel, pastInfo);
const battles = Array.isArray(result?.battles) ? result.battles : [];
if (!loading && !errorMessage && battles.length) {
const count = document.createElement('span');
count.className = 'titan-past-count';
count.textContent = t('sameTitanMatches', { count: battles.length });
pastHead.appendChild(count);
}
pastSection.appendChild(pastHead);
const renderPastTotems = (parent, totems) => {
const rows = Array.isArray(totems) ? totems : [];
if (!rows.length) return;
const wrap = document.createElement('div');
wrap.className = 'titan-past-totems';
for (const totem of rows) {
const row = document.createElement('div');
row.className = 'titan-past-totem';
const meta = [];
if (totem.level != null) meta.push(t('levelShort', { level: totem.level }));
if (totem.star != null) meta.push(`★${totem.star}`);
const skillText = (totem.skills ?? []).map(skill => {
const typeLabel = skill.type || t('skillShort', { index: skill.index });
const skillName = skill.name || (skill.id != null ? `#${skill.id}` : '−');
const rank = skill.rank != null ? formatTitanRankShort(skill.rank) : '';
return `${typeLabel}: ${skillName}${rank ? ` · ${rank}` : ''}`;
}).join(' / ');
const strong = document.createElement('strong');
strong.textContent = totem.name || `${totem.element || ''} Spirit Totem`;
row.appendChild(strong);
const detail = [...meta, skillText].filter(Boolean).join(' · ');
if (detail) row.appendChild(document.createTextNode(` · ${detail}`));
wrap.appendChild(row);
}
parent.appendChild(wrap);
};
const renderPastSide = async (card, battle, sideName) => {
const side = battle?.[sideName] ?? null;
if (!side) return;
const sideBox = document.createElement('div');
const matched = Array.isArray(battle?.matchedSides) && battle.matchedSides.includes(sideName);
sideBox.className = `titan-side${matched ? ' match' : ''}`;
const sideHead = document.createElement('div');
sideHead.className = 'titan-side-head';
const sideTitle = document.createElement('span');
sideTitle.className = 'titan-side-title';
sideTitle.textContent = sideName === 'attacker' ? t('attacker') : t('defender');
const player = document.createElement('span');
player.className = 'titan-side-player';
player.textContent = side.playerName || '';
sideHead.append(sideTitle, player);
if (matched) {
const badge = document.createElement('span');
badge.className = 'titan-match-badge';
badge.textContent = t('matchUpper');
sideHead.appendChild(badge);
}
sideBox.appendChild(sideHead);
const icons = document.createElement('div');
icons.className = 'icons';
for (const titan of side.titans ?? []) {
await addUnitBox(icons, titan.id, 'hero', titan.name || `Titan ${titan.id}`);
}
sideBox.appendChild(icons);
renderPastTotems(sideBox, side.totems);
if (sideName === 'defender' && Array.isArray(battle?.historicalBuffs) && battle.historicalBuffs.length) {
const buff = document.createElement('div');
buff.className = 'titan-buff';
const values = battle.historicalBuffs.map(row => {
const amount = `${Number(row.value) >= 0 ? '+' : ''}${row.value}${row.unit || ''}`;
return `${row.name || row.effectKey}: ${amount}`;
});
buff.textContent = `${t('battleBuff')}: ${values.join(' / ')}`;
sideBox.appendChild(buff);
}
card.appendChild(sideBox);
};
if (!loading && !errorMessage) {
if (!battles.length) {
const empty = document.createElement('div');
empty.className = 'empty';
empty.textContent = t('noMatchingTitanBattleLog');
pastSection.appendChild(empty);
} else {
for (const battle of battles) {
const card = document.createElement('div');
card.className = 'titan-past-card';
const battleHead = document.createElement('div');
battleHead.className = 'titan-battle-head';
const date = document.createElement('span');
date.className = 'titan-battle-date';
date.textContent = battle.dateString || '';
const place = document.createElement('span');
place.className = 'titan-battle-place';
const position = battle.position != null && String(battle.position) !== '' ? ` #${battle.position}` : '';
place.textContent = `${battle.building || ''}${position}`.trim();
const resultBadge = document.createElement('span');
resultBadge.className = 'titan-battle-result';
resultBadge.textContent = battle.attackerWon === true
? `${t('attacker')} ${t('winUpper')}`
: battle.attackerWon === false
? `${t('defender')} ${t('winUpper')}`
: t('unknownResult');
battleHead.append(date, place, resultBadge);
card.appendChild(battleHead);
await renderPastSide(card, battle, 'attacker');
await renderPastSide(card, battle, 'defender');
pastSection.appendChild(card);
}
}
}
if (renderId !== renderSequence || !host.isConnected) return;
content.appendChild(pastSection);
if (loading) {
const loadingRow = document.createElement('div');
loadingRow.className = 'section loading';
loadingRow.textContent = t('loadingReference');
content.appendChild(loadingRow);
} else if (errorMessage) {
const error = document.createElement('div');
error.className = 'section error';
error.textContent = errorMessage;
content.appendChild(error);
}
requestAnimationFrame(placeReferenceNearGamePopup);
return;
}
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 ?? null;
const missingAssetText = [];
const appendInlineCurrentMessage = (message, title = message) => {
const text = document.createElement('div');
text.className = 'current-inline-message empty';
text.textContent = message;
text.title = title;
currentRow.appendChild(text);
};
const appendUnknownCurrentCell = (title = t('stateUnknown')) => {
const cell = document.createElement('div');
cell.className = 'current-cell';
cell.title = title;
const icon = document.createElement('div');
icon.className = 'current-icon unknown-equipment';
const p = document.createElement('span');
p.className = 'placeholder';
p.textContent = '?';
icon.appendChild(p);
const value = document.createElement('div');
value.className = 'pattern-value';
cell.append(icon, value);
currentRow.appendChild(cell);
};
if (!currentDefense) {
appendUnknownCurrentCell(t('referenceCouldNotBeLoaded'));
} else if (!currentDefense.bannerId) {
appendInlineCurrentMessage(t('noWarFlag'));
} else {
const flagCell = document.createElement('div');
flagCell.className = 'current-cell';
const resolvedFlagName = currentDefense.flagName || warFlagIdText(currentDefense.bannerId);
flagCell.title = resolvedFlagName;
const flagIcon = document.createElement('div');
flagIcon.className = 'current-icon';
let flagRendered = false;
if (currentFlagSpec) {
const flagSprite = await createSpriteFromSpec(currentFlagSpec, 46, 'flag-sprite', flagCell.title);
if (flagSprite) {
flagIcon.appendChild(flagSprite);
flagRendered = true;
}
}
if (!flagRendered) {
const p = document.createElement('span');
p.className = 'placeholder known-fallback';
p.textContent = shortIdentityFallback(resolvedFlagName, currentDefense.bannerId);
flagIcon.appendChild(p);
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);
const patternRows = Array.isArray(currentDefense.patterns) ? currentDefense.patterns : [];
const knownPatternRows = patternRows.filter(item => Number.isInteger(Number(item?.slot)) && Number(item.slot) >= 0 && Number(item.slot) <= 2);
const unknownPatternSlots = new Set(
(currentDefense.patternSlotsUnknown ?? [])
.map(Number)
.filter(slot => Number.isInteger(slot) && slot >= 0 && slot <= 2)
);
if (!knownPatternRows.length && !unknownPatternSlots.size) {
appendInlineCurrentMessage(t('noPatternsEquipped'));
} else {
for (let patternSlot = 0; patternSlot < 3; patternSlot += 1) {
const row = knownPatternRows.find(item => Number(item.slot) === patternSlot) ?? null;
const cell = document.createElement('div');
cell.className = 'current-cell';
const slotUnknown = unknownPatternSlots.has(patternSlot);
const patternLabel = row?.name || (row ? `Pattern ${patternSlot + 1}` : t('patternSlotEmpty', { slot: patternSlot + 1 }));
cell.title = slotUnknown ? t('stateUnknown') : patternLabel;
const icon = document.createElement('div');
icon.className = 'current-icon';
if (slotUnknown) {
icon.classList.add('unknown-equipment');
const p = document.createElement('span');
p.className = 'placeholder';
p.textContent = '?';
icon.appendChild(p);
} else if (!row) {
icon.classList.add('empty-pattern-slot');
const p = document.createElement('span');
p.className = 'placeholder';
p.textContent = t('emptyPatternShort');
icon.appendChild(p);
} else {
if (row.pattern && row.colorTier) {
icon.classList.add('pattern-frame', `pattern-tier-${row.colorTier}`);
}
const nativePatternIcon = row.pattern ? await createPatternIconElement(row.pattern, 46) : null;
if (nativePatternIcon) icon.appendChild(nativePatternIcon);
else {
const p = document.createElement('span');
p.className = 'placeholder known-fallback';
p.textContent = shortIdentityFallback(patternLabel, row.id);
icon.appendChild(p);
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 = 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 pastLastText = ref?.dateString ? t('lastSeen', { date: ref.dateString }) : '';
label.append(labelText, matchInfo);
pastHead.append(label);
refSection.appendChild(pastHead);
if (pastLastText) {
const pastLast = document.createElement('div');
pastLast.className = 'last last-row';
pastLast.textContent = pastLastText;
refSection.appendChild(pastLast);
}
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';
const pastWarFlagId = Number(ref.warFlag?.bannerId);
const hasKnownPastWarFlag = Number.isFinite(pastWarFlagId) && pastWarFlagId > 0;
if (!ref.warFlag) {
flagBox.title = t('noWarFlag');
const p = document.createElement('span'); p.className = 'placeholder'; p.textContent = t('pastSetupNoWarFlagShort'); flagBox.appendChild(p);
} else if (!hasKnownPastWarFlag) {
flagBox.title = t('stateUnknown');
const p = document.createElement('span'); p.className = 'placeholder'; p.textContent = '?'; flagBox.appendChild(p);
} else if (nativeFlagSpec) {
flagBox.title = getWarFlagDisplayName(pastWarFlagId);
const flagSprite = await createSpriteFromSpec(nativeFlagSpec, 46, 'flag-sprite', flagBox.title);
if (flagSprite) flagBox.appendChild(flagSprite);
else { const p = document.createElement('span'); p.className = 'placeholder'; p.textContent = getWarFlagDisplayName(pastWarFlagId); flagBox.appendChild(p); }
} else {
flagBox.title = getWarFlagDisplayName(pastWarFlagId);
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), 22, '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);
usedHead.append(usedLabel);
usedSection.appendChild(usedHead);
const petIds = [...new Set((result?.used?.petIds ?? []).map(Number).filter(id => Number.isFinite(id) && id > 0))];
const usedLastText = petIds.length && result?.used?.lastSeen ? t('lastSeen', { date: result.used.lastSeen }) : '';
if (usedLastText) {
const usedLast = document.createElement('div');
usedLast.className = 'last last-row';
usedLast.textContent = usedLastText;
usedSection.appendChild(usedLast);
}
if (!petIds.length) {
const none = document.createElement('div');
none.className = 'empty';
none.textContent = t('noPatronUse');
usedSection.appendChild(none);
} else {
const usedIcons = document.createElement('div');
usedIcons.className = 'icons';
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) {
const nextVisible = Boolean(visible);
if (nextVisible) ensureUpdateAvailabilityCheck();
const currentlyVisible = host.style.display !== 'none';
// GW polls its scope every REFRESH_MS. Reapplying the same visible state
// used to schedule a position/size correction on every poll, which fought
// active pointer drag/resize and made the Reference panel flicker or snap.
if (currentlyVisible === nextVisible) return;
host.style.display = nextVisible ? '' : 'none';
if (nextVisible) 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();
ensureUpdateAvailabilityCheck();
return patronReferenceView;
}
function getCurrentReferenceSession() {
if (nativeCowSession?.target) {
try {
const popupOpen = nativeCowSession.popup &&
getOpenPopupsByClass(CLASS.demoPopup).includes(nativeCowSession.popup);
if (popupOpen || (nativeCowSession.attackEditorSeen && hasOpenTrainingUi())) {
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 || current.session?.referenceHiddenForAttackEditor) {
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 {
position:relative; 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); }
.update-dot-mini { position:absolute; right:3px; top:3px; width:9px; height:9px; border-radius:50%; background:#f04455; border:1px solid #ffd6dc; box-shadow:0 0 5px rgba(240,68,85,.85); pointer-events:none; }
.update-tip-mini { position:fixed; left:8px; top:8px; z-index:2147483647; width:min(260px, calc(100vw - 16px)); padding:8px 9px; border:1px solid rgba(255,255,255,.2); border-radius:7px; background:rgb(16,18,23); color:#eee; font:12px/1.42 Arial,sans-serif; white-space:pre-line; box-shadow:0 5px 17px rgba(0,0,0,.55); pointer-events:none; display:none; text-align:left; }
.mini.update-available:hover .update-tip-mini, .mini.update-available:focus .update-tip-mini { display:block; }
</style>
<button class="mini" type="button" aria-label="${t('openDefenseReference')}" title="${t('defenseReference')} v${VERSION}">
<span class="mini-icon">⚔</span><span class="update-dot-mini" hidden></span><span class="update-tip-mini"></span>
</button>
`;
const miniButton = shadow.querySelector('button');
const miniUpdateDot = shadow.querySelector('.update-dot-mini');
const miniUpdateTip = shadow.querySelector('.update-tip-mini');
const unsubscribeMiniUpdate = subscribeUpdateAvailability(state => {
const show = Boolean(state.updateAvailable && state.latestVersion);
miniButton.classList.toggle('update-available', show);
miniUpdateDot.hidden = !show;
miniUpdateTip.textContent = show ? updateTooltipText(state.latestVersion) : '';
});
host.__hwctUpdateUnsubscribe = unsubscribeMiniUpdate;
const positionMiniUpdateTip = () => {
if (!miniButton.classList.contains('update-available')) return;
const anchorRect = miniButton.getBoundingClientRect();
const margin = 8, gap = 6;
const previousDisplay = miniUpdateTip.style.display;
const previousVisibility = miniUpdateTip.style.visibility;
miniUpdateTip.style.visibility = 'hidden';
miniUpdateTip.style.display = 'block';
const rect = miniUpdateTip.getBoundingClientRect();
let left = anchorRect.left;
let top = anchorRect.bottom + gap;
if (top + rect.height > window.innerHeight - margin) top = anchorRect.top - rect.height - gap;
left = clamp(left, margin, Math.max(margin, window.innerWidth - rect.width - margin));
top = clamp(top, margin, Math.max(margin, window.innerHeight - rect.height - margin));
miniUpdateTip.style.left = `${Math.round(left)}px`;
miniUpdateTip.style.top = `${Math.round(top)}px`;
miniUpdateTip.style.visibility = previousVisibility;
miniUpdateTip.style.display = previousDisplay;
};
miniButton.addEventListener('pointerenter', () => requestAnimationFrame(positionMiniUpdateTip));
miniButton.addEventListener('focus', () => requestAnimationFrame(positionMiniUpdateTip));
ensureUpdateAvailabilityCheck();
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) {
const keepGwSelectionHidden = Boolean(activeDefenseSession && hasOpenTrainingUi());
if (!keepGwSelectionHidden) mainPanelController?.showAfterReference?.();
else mainPanelController?.hideForReference?.();
}
if (offerReopen && getCurrentReferenceSession()) showReferenceReopenLauncher();
}
function showActivePatronReference() {
const current = getCurrentReferenceSession();
if (!current) return false;
const { session, target } = current;
hideReferenceReopenLauncher();
if (session.referenceHiddenForAttackEditor) return false;
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;
}
if (session.referenceLoading) {
view.setLoading(target);
return true;
}
startPatronReferenceLoad(target, session);
return true;
}
function getReferenceViewForRender(session, token) {
if (token !== patronRequestToken) return null;
if (session && (!isReferenceSessionActive(session) || session.referenceHiddenForAttackEditor)) return null;
if (!patronReferenceView?.host?.isConnected) return null;
return patronReferenceView;
}
function startPatronReferenceLoad(target, session = activeDefenseSession) {
hideReferenceReopenLauncher();
const token = ++patronRequestToken;
if (target?.kind === 'GW') mainPanelController?.hideForReference?.();
const view = ensurePatronReferenceView();
if (target?.referenceType === 'titan') {
if (!TITAN_PAST_REFERENCE_ENABLED) {
// Initial Titan release intentionally stops at the live Current Defense.
// Do not touch war-history APIs/replays until Past Battles returns in a
// separately tested UI redesign.
const result = {
currentTitanDefense: target.currentTitanDefense ?? null,
battles: [],
stats: { source: 'current-titan-defense', pastReferenceEnabled: false },
};
if (session && isReferenceSessionActive(session)) {
session.referenceResult = result;
session.referenceError = null;
session.referenceLoading = false;
}
const renderView = getReferenceViewForRender(session, token);
if (!renderView) return;
log('Titan Current Reference', target);
Promise.resolve(renderView.render(target, result)).catch(error => {
warn('TITAN_CURRENT_REFERENCE_RENDER_FAILED', error);
renderView.setError(t('failedDisplayDefenseReference'), target);
});
return;
}
view.setLoading(target);
if (session) session.referenceLoading = true;
Promise.resolve()
.then(() => loadTitanPastReference(target))
.then(async past => {
const result = {
currentTitanDefense: target.currentTitanDefense ?? null,
battles: Array.isArray(past?.battles) ? past.battles : [],
stats: past?.stats ?? { source: 'titan-past-reference' },
};
if (session && isReferenceSessionActive(session)) {
session.referenceResult = result;
session.referenceError = null;
session.referenceLoading = false;
}
const renderView = getReferenceViewForRender(session, token);
if (!renderView) return;
log('Titan Past Reference', target, result.stats);
await renderView.render(target, result);
})
.catch(async error => {
const result = {
currentTitanDefense: target.currentTitanDefense ?? null,
battles: [],
stats: { source: 'current-titan-defense', pastReferenceError: String(error?.message ?? error) },
};
if (session && isReferenceSessionActive(session)) {
// Current Reference remains usable even if historical log lookup fails.
session.referenceResult = result;
session.referenceError = null;
session.referenceLoading = false;
}
const renderView = getReferenceViewForRender(session, token);
if (!renderView) return;
warn('TITAN_PAST_REFERENCE_FAILED', error);
await renderView.render(target, result, { errorMessage: t('couldNotLoadBattleLogReference') });
});
return;
}
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;
}
const renderView = getReferenceViewForRender(session, token);
if (!renderView) return;
log('Patron Reference', target, result.stats);
await renderView.render(target, result);
})
.catch(error => {
if (session && isReferenceSessionActive(session)) {
session.referenceError = error;
session.referenceLoading = false;
}
const renderView = getReferenceViewForRender(session, token);
if (!renderView) return;
warn('PATRON_REFERENCE_FAILED', error);
renderView.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 target metadata before opening Combat Training. Reference failures must
// never block MAX itself. Hero history is loaded later; Titan Current Reference
// is captured from the live target slot now.
let referenceTarget = null;
if (mode === MODES.MAX && (snapshot.kind === 'GW' || snapshot.kind === 'CoW')) {
try {
referenceTarget = data.isHero
? getPatronTarget(snapshot, data.item)
: buildTitanReferenceTargetFromSlot(snapshot.kind, data.item.slot, {
slotNumber: data.item.slotNumber,
building: data.item.building,
mediator: snapshot.mediator,
popup: snapshot.popup,
});
} catch (error) {
warn(data.isHero ? 'PATRON_TARGET_SKIPPED' : 'TITAN_REFERENCE_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 || referenceTarget?.building || '',
teamKey: data.item.teamKey || getSlotTeamKey(data.team),
defenseEditorOpened,
patronTarget: referenceTarget,
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(1, 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; }
.update-indicator { flex:0 0 auto; display:inline-flex; align-items:center; justify-content:center; width:12px; height:12px; border:0; padding:0; background:transparent; }
.update-dot { width:8px; height:8px; border-radius:50%; background:#f04455; border:1px solid #ffd6dc; box-shadow:0 0 5px rgba(240,68,85,.85); }
.update-tip { position:fixed; left:8px; top:8px; z-index:2147483647; width:min(260px, calc(100vw - 16px)); padding:8px 9px; border:1px solid rgba(255,255,255,.2); border-radius:7px; background:rgb(16,18,23); color:#eee; font-weight:400; font-size:12px; line-height:1.42; white-space:pre-line; box-shadow:0 5px 17px rgba(0,0,0,.55); pointer-events:none; display:none; }
.version-hover.update-available:hover .update-tip, .version-hover.update-available:focus .update-tip { display:block; }
.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: calc(4px * var(--hwct-main-scale, 1)); }
.slot-row {
display: grid;
grid-template-columns: calc(34px * var(--hwct-main-scale, 1)) minmax(0, 1fr);
align-items: center;
min-height: calc(29px * var(--hwct-main-scale, 1));
gap: calc(5px * var(--hwct-main-scale, 1));
padding: calc(2px * var(--hwct-main-scale, 1)) calc(4px * var(--hwct-main-scale, 1));
border-radius: calc(5px * var(--hwct-main-scale, 1));
background: rgba(255,255,255,.035);
}
.slot-row.inactive { opacity: .48; }
.slot-btn {
position: relative;
width: calc(32px * var(--hwct-main-scale, 1));
height: calc(25px * var(--hwct-main-scale, 1));
padding: 0; border: 0;
border-radius: calc(5px * var(--hwct-main-scale, 1));
background: #ddb15e; color: #21180a; cursor: pointer;
font:700 var(--hwct-slot-font-size, .95em) Arial;
}
.slot-btn:disabled { background: #777; color: #ddd; cursor: default; }
.slot-btn.my-target::after {
content: ''; position: absolute;
right: calc(-4px * var(--hwct-main-scale, 1));
top: calc(-4px * var(--hwct-main-scale, 1));
width: calc(9px * var(--hwct-main-scale, 1));
height: calc(9px * var(--hwct-main-scale, 1));
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 {
position:relative; 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); }
.update-dot-mini { position:absolute; right:3px; top:3px; width:9px; height:9px; border-radius:50%; background:#f04455; border:1px solid #ffd6dc; box-shadow:0 0 5px rgba(240,68,85,.85); pointer-events:none; }
.update-tip-mini { position:fixed; left:8px; top:8px; z-index:2147483647; width:min(260px, calc(100vw - 16px)); padding:8px 9px; border:1px solid rgba(255,255,255,.2); border-radius:7px; background:rgb(16,18,23); color:#eee; font:12px/1.42 Arial,sans-serif; white-space:pre-line; box-shadow:0 5px 17px rgba(0,0,0,.55); pointer-events:none; display:none; text-align:left; }
.mini.update-available:hover .update-tip-mini, .mini.update-available:focus .update-tip-mini { display:block; }
.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 id="main-version-hover" class="version-hover" tabindex="-1"><span class="version">· v${VERSION}</span><span id="main-update" class="update-indicator" hidden aria-hidden="true"><span class="update-dot"></span></span><span id="main-update-tip" class="update-tip"></span></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><span id="main-mini-update-dot" class="update-dot-mini" hidden></span><span id="main-mini-update-tip" class="update-tip-mini"></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 mainVersionHover = shadow.getElementById('main-version-hover');
const mainUpdate = shadow.getElementById('main-update');
const mainUpdateTip = shadow.getElementById('main-update-tip');
const mainMiniUpdateDot = shadow.getElementById('main-mini-update-dot');
const mainMiniUpdateTip = shadow.getElementById('main-mini-update-tip');
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');
function positionMainUpdateTip(anchorElement, tip) {
if (!anchorElement || !tip) return;
const anchorRect = anchorElement.getBoundingClientRect();
const margin = 8, gap = 6;
const previousDisplay = tip.style.display;
const previousVisibility = tip.style.visibility;
tip.style.visibility = 'hidden';
tip.style.display = 'block';
const rect = tip.getBoundingClientRect();
let left = anchorRect.left;
let top = anchorRect.bottom + gap;
if (top + rect.height > window.innerHeight - margin) top = anchorRect.top - rect.height - gap;
left = clamp(left, margin, Math.max(margin, window.innerWidth - rect.width - margin));
top = clamp(top, margin, Math.max(margin, window.innerHeight - rect.height - margin));
tip.style.left = `${Math.round(left)}px`;
tip.style.top = `${Math.round(top)}px`;
tip.style.visibility = previousVisibility;
tip.style.display = previousDisplay;
}
const unsubscribeMainUpdate = subscribeUpdateAvailability(state => {
const show = Boolean(state.updateAvailable && state.latestVersion);
mainVersionHover.classList.toggle('update-available', show);
mainVersionHover.tabIndex = show ? 0 : -1;
mainVersionHover.setAttribute('aria-label', show ? t('updateAvailable') : '');
mainUpdate.hidden = !show;
mainUpdateTip.textContent = show ? updateTooltipText(state.latestVersion) : '';
miniButton.classList.toggle('update-available', show);
mainMiniUpdateDot.hidden = !show;
mainMiniUpdateTip.textContent = show ? updateTooltipText(state.latestVersion) : '';
});
mainVersionHover.addEventListener('pointerenter', () => {
if (!mainVersionHover.classList.contains('update-available')) return;
requestAnimationFrame(() => positionMainUpdateTip(mainVersionHover, mainUpdateTip));
});
mainVersionHover.addEventListener('focus', () => {
if (!mainVersionHover.classList.contains('update-available')) return;
requestAnimationFrame(() => positionMainUpdateTip(mainVersionHover, mainUpdateTip));
});
miniButton.addEventListener('pointerenter', () => requestAnimationFrame(() => positionMainUpdateTip(miniButton, mainMiniUpdateTip)));
miniButton.addEventListener('focus', () => requestAnimationFrame(() => positionMainUpdateTip(miniButton, mainMiniUpdateTip)));
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`;
}
function updateMainPanelScale(width = panel.getBoundingClientRect().width || MAIN_PANEL_DEFAULT_WIDTH) {
const scale = clamp(Number(width) / MAIN_PANEL_DEFAULT_WIDTH, 0.72, 2.00);
host.style.setProperty('--hwct-main-scale', String(scale));
const baseFontSize = clamp(Math.round(Number(uiState.fontSize) || UI_FONT_DEFAULT), UI_FONT_MIN, UI_FONT_MAX);
host.style.setProperty('--hwct-slot-font-size', `${(baseFontSize * 0.95 * scale).toFixed(2)}px`);
}
updateMainPanelScale();
let scopeVisible = false;
let hiddenForReference = false;
function updateHostVisibility() {
const showMain = scopeVisible && !hiddenForReference;
const currentlyVisible = host.style.display !== 'none';
if (currentlyVisible !== showMain) {
host.style.display = showMain ? '' : 'none';
if (showMain) requestAnimationFrame(applyPosition);
}
if (showMain) ensureUpdateAvailabilityCheck();
if (patronReferenceView?.getKind?.() === 'GW') patronReferenceView.setScopeVisible(scopeVisible && hiddenForReference);
}
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`;
updateMainPanelScale();
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,
onResize: rect => updateMainPanelScale(rect.width),
onResizeEnd: rect => {
updateMainPanelScale(rect.width);
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();
updateMainPanelScale(rect.width);
if (rect.height > window.innerHeight) panel.style.height = `${Math.max(1, window.innerHeight)}px`;
applyPosition();
});
});
function stateText(state) {
switch (state) {
case 'ready': return t('stateAvailable');
case 'empty': return t('stateEmpty');
case 'defeated': return t('stateDefeated');
case 'captured': return t('stateCaptured');
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;
nativeCowSession.attackEditorClosedAt = 0;
hideReferenceForAttackEditor(nativeCowSession);
}
const launchDetected = (
nativeCowSession.attackEditorSeen &&
hasBattleTeamCommitTransition(nativeCowSession)
);
const battleScreenDetected = hasOpenBattlePreloader() || hasOpenBattleView();
if (launchDetected || battleScreenDetected) {
nativeCowSession = null;
hidePatronReference({ restoreMain: false });
return true;
}
if (attackEditorOpen) return false;
if (nativeCowSession.attackEditorSeen && !nativeCowSession.attackEditorClosedAt) {
nativeCowSession.attackEditorClosedAt = Date.now();
}
const cowAttackEditorClosedMs = nativeCowSession.attackEditorClosedAt
? Date.now() - nativeCowSession.attackEditorClosedAt
: 0;
if (
nativeCowSession.referenceHiddenForAttackEditor &&
(demoOpen || defenseEditorOpen)
) {
if (cowAttackEditorClosedMs < ATTACK_EDITOR_RESTORE_GRACE_MS) return false;
restoreReferenceAfterAttackEditor(nativeCowSession);
return false;
}
if (
nativeCowSession.attackEditorSeen &&
!demoOpen &&
!defenseEditorOpen
) {
if (cowAttackEditorClosedMs < ATTACK_EDITOR_RETURN_GRACE_MS) return false;
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 opens, hide only the Reference UI. The reusable Reference data
// stays alive until a battle launch or the whole editor stack closes.
if (attackEditorOpen) {
activeDefenseSession.attackEditorSeen = true;
activeDefenseSession.attackEditorClosedAt = 0;
hideReferenceForAttackEditor(activeDefenseSession);
}
const launchDetected = (
activeDefenseSession.attackEditorSeen &&
hasBattleTeamCommitTransition(activeDefenseSession)
);
const battleScreenDetected = hasOpenBattlePreloader() || hasOpenBattleView();
if (launchDetected || battleScreenDetected) {
activeDefenseSession = null;
hidePatronReference({ restoreMain: false });
hiddenForReference = true;
updateHostVisibility();
setStatus('');
return true;
}
if (attackEditorOpen) return false;
if (activeDefenseSession.attackEditorSeen && !activeDefenseSession.attackEditorClosedAt) {
activeDefenseSession.attackEditorClosedAt = Date.now();
}
const gwAttackEditorClosedMs = activeDefenseSession.attackEditorClosedAt
? Date.now() - activeDefenseSession.attackEditorClosedAt
: 0;
if (
activeDefenseSession.referenceHiddenForAttackEditor &&
(demoOpen || defenseEditorOpen)
) {
if (gwAttackEditorClosedMs < ATTACK_EDITOR_RESTORE_GRACE_MS) return false;
restoreReferenceAfterAttackEditor(activeDefenseSession);
updateHostVisibility();
return false;
}
// Conservative fallback: if the whole Demo Battle editor stack disappears
// after Attack Team and does not quickly return to Create Hero battle, the
// Reference is no longer relevant.
if (
activeDefenseSession.attackEditorSeen &&
!attackEditorOpen &&
!demoOpen &&
!defenseEditorOpen
) {
if (gwAttackEditorClosedMs < ATTACK_EDITOR_RETURN_GRACE_MS) return false;
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;
}
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,
attackEditorClosedAt: 0,
referenceHiddenForAttackEditor: 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 && status.textContent === t('openGwAttackTarget')) {
setStatus('');
}
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.displayState ?? slotInfo.state)}${assignmentHint}`;
slotButton.addEventListener('click', () => handleLaunch(slotInfo, slotButton));
const name = document.createElement('div');
name.className = `player-name${slotInfo.canLaunch ? ' launchable' : ''}`;
const suffix = stateText(slotInfo.displayState ?? slotInfo.state);
name.textContent = suffix || '';
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);
unsubscribeMainUpdate?.();
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,
getIconDiagnostics: () => JSON.parse(JSON.stringify([...unitIconDiagnostics.values()])),
});
log('loaded');
}
})();