☰

hv-equip-tip

可复用的 Hentaiverse 装备悬停浮层(Shadow DOM)。

이 스크립트는 직접 설치하는 용도가 아닙니다. 다른 스크립트에서 메타 지시문 // @require https://update.sleazyfork.org/scripts/601302/1969559/hv-equip-tip.js을(를) 사용하여 포함하는 라이브러리입니다.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램을 먼저 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         HV 装备浮层
// @namespace    https://hentaiverse.org/
// @version      1.1.4
// @description  可复用的 Hentaiverse 装备悬停浮层(Shadow DOM)。
// @author       ssnangua
// @license      MIT
// @grant        GM_xmlhttpRequest
// @connect      hentaiverse.org
// @connect      alt.hentaiverse.org
// ==/UserScript==

/**
 * 用法:
 * // @require      https://.../hv-equip-tip.lib.user.js
 * // @grant        GM_xmlhttpRequest
 * // @connect      hentaiverse.org
 * // @connect      alt.hentaiverse.org
 *
 * HvEquipTip.attach()                   // 开始监听悬停。重复调用不会重复绑定
 * HvEquipTip.isTranslate()              // 当前是否汉化
 * HvEquipTip.isTranslate(true|false)    // 随时开关。浮层正开着会立刻换语言
 * HvEquipTip.url(href)                  // 规范的装备页地址,不是装备链接时返回 ""
 * HvEquipTip.open(url)                  // 用 450×520 的小窗口打开
 */

(function (root) {
  "use strict";

  const pageWindow = typeof unsafeWindow === "undefined" ? window : unsafeWindow;

  // 与 Hentaiverse 汉化包「外显词缀」同一套译名和底色。长词在前,避免 Power 吃掉 Power Armor。
  const EQ_PHRASES = [
    [
      " of the Thrice-blessed",
      " 恩典(圣抗+)",
      "background: #ffffff; color: #5c5a5a; padding: 0 1px;",
    ],
    [" of the Stone-skinned", " 石肤者(物减伤+)"],
    [
      " of the Thunder-child",
      " 雷之子(电抗+)",
      "background: #ffff00; color: #9f9f16; padding: 0 1px;",
    ],
    [" of the Battlecaster", " 战法师(魔耗-魔命+无干涉)"],
    [" of the Curse-weaver", " 织咒者(减益熟练+)"],
    [" of the Earth-walker", " 地行者(增益熟练+)"],
    [" of the Elementalist", " 元素使(元素熟练+)"],
    [" of the Shadowdancer", " 影武者(闪避攻暴+)"],
    [" of the Demon-fiend", " 恶魔(黑暗熟练+)"],
    [" of the Heaven-sent", " 天堂(神圣熟练+)"],
    [
      " of the Spirit-ward",
      " 魂护(暗抗+)",
      "background: #cccccc; color: #000000; padding: 0 1px;",
    ],
    [
      " of the Fire-eater",
      " 吞火者(火抗+)",
      "background: #ffa6a6; color: #000000; padding: 0 1px;",
    ],
    [
      " of the Frost-born",
      " 冰诞者(冰抗+)",
      "background: #a0f4f4; color: #000000; padding: 0 1px;",
    ],
    [
      " of the Wind-waker",
      " 驭风者(风抗+)",
      "background: #b1f9b1; color: #000000; padding: 0 1px;",
    ],
    [" of the Arcanist", " 秘法(智力智慧魔命+)"],
    [" of the Illithid", " 汲灵(5%回血+吸魔)"],
    [" of Destruction", " 毁灭(法伤+)"],
    [" of the Banshee", " 女妖(5%回血+吸灵)"],
    [" of the Barrier", " 屏障(格挡+)"],
    [" of the Cheetah", " 猎豹(敏捷+)"],
    [" of the Raccoon", " 浣熊(灵巧+)"],
    [" of the Vampire", " 吸血鬼(10%回血)"],
    [" of Deflection", " 偏转(刺减伤+)"],
    [" of Protection", " 保护(物减伤+)"],
    [" of the Nimble", " 灵活(招架+)"],
    [" of the Turtle", " 乌龟(体质+)"],
    [
      "of Destruction",
      " 毁灭(法伤+)",
      "background: #9400d3; color: #ffffff; padding: 0 1px;",
    ],
    ["Quintessential", "第五元素"],
    [" of Dampening", " 抑制(打减伤+)"],
    [" of Priestess", " 牧师"],
    [" of Slaughter", " 杀戮(攻击+)"],
    [" of Stoneskin", " 石肤(斩减伤+)"],
    [" of Swiftness", " 迅捷(攻速+)"],
    [" of the Fleet", " 迅捷(闪避+)"],
    [" of Negation", " 否定(抵抗+)"],
    [" of the Hulk", " 巨物(虚空抗+)"],
    [
      "Force Shield",
      "力场盾(副)",
      "background: #ffa500; color: #000000; padding: 0 1px;",
    ],
    [
      "of Slaughter",
      " 杀戮(攻击+)",
      "background: #FF0000; color: #FFFFFF; padding: 0 1px;",
    ],
    ["Tower Shield", "🚪塔盾(副)"],
    [" of Balance", " 平衡(命中暴伤+)"],
    [" of the Fox", " 狐狸(智力+)"],
    [" of the Owl", " 猫头鹰(智慧+)"],
    [" of Warding", " 护佑(魔减伤+)"],
    ["Breastplate", "🥼护胸"],
    ["Kite Shield", "🔰鸢盾(副)"],
    [
      "Magnificent",
      "☆史诗☆",
      "background: #a6daf6; color: #000; border-radius: 2px; vertical-align: baseline; box-shadow: 0 0 0 1px #a6daf6;",
    ],
    [
      "of Heimdall",
      " 海姆达(圣法伤+)",
      "background: #ffffff; color: #000000; padding: 0 1px;",
    ],
    [
      "of Niflheim",
      " 尼芙菲姆(冰法伤+)",
      "background: #94c2f5; color: #000000; padding: 0 1px;",
    ],
    [
      "Power Armor",
      "<span style='background: #ffa500; color: #000000; padding: 0 1px;'>动力(重)</span> 🥼盔甲",
      "background: transparent; color: inherit; padding: 0;",
    ],
    ["Swordchucks", "⚔︎锁链双剑(双)"],
    [
      "Tempestuous",
      "风暴(风法伤+)",
      "background: #7ff97c; color: #000000; padding: 0 1px;",
    ],
    [" of the Ox", " 公牛(力量+)"],
    [" of Turtle", " 乌龟(体质+)"],
    ["Great Mace", "🔨战锤(双)"],
    [
      "of Mjolnir",
      " 姆乔尔尼尔(电法伤+)",
      "background: #f4f375; color: #000000; padding: 0 1px;",
    ],
    ["Reinforced", "加固的(斩打刺减伤+)"],
    ["Shortsword", "⚔️短剑(单)"],
    [" of Focus", " 专注(魔命+法暴伤+魔耗-)"],
    [
      "Drakehide",
      "龙皮(轻)",
      "background: #666666; color: #FFFFFF; padding: 0 1px;",
    ],
    [
      "Exquisite",
      "✧精良✧",
      "background: #d7e698; color: #000; border-radius: 2px; vertical-align: baseline; box-shadow: 0 0 0 1px #d7e698;",
    ],
    ["Gauntlets", "🤲手甲"],
    [
      "Legendary",
      "✪传奇✪",
      "background: #ffbbff; color: #000; border-radius: 2px; vertical-align: baseline; box-shadow: 0 0 0 1px #ffbbff;",
    ],
    ["Longsword", "⚔️长剑(双)"],
    [
      "of Fenrir",
      " 芬里尔(暗法伤+)",
      "background: #000000; color: #ffffff; padding: 0 1px;",
    ],
    ["Shielding", "盾化的(格挡+)"],
    ["Wakizashi", "🔪脇差(单)"],
    [
      "Ethereal",
      "虚空(无负重干涉)",
      "background: #ffffff; color: #5c5a5a; padding: 0 1px;",
    ],
    [
      "Gossamer",
      "薄纱(布)",
      "background: #FFFFFF; color: #000000; padding: 0 1px;",
    ],
    [
      "Hallowed",
      "神圣(圣法伤+)",
      "background: #ffffff; color: #000000; padding: 0 1px;",
    ],
    [
      "Ironsilk",
      "铁丝(布)",
      "background: #FFFFFF; color: #000000; padding: 0 1px;",
    ],
    ["Leggings", "🦵绑腿"],
    [
      "of Freyr",
      " 弗瑞尔(风法伤+)",
      "background: #7ff97c; color: #000000; padding: 0 1px;",
    ],
    [
      "of Surtr",
      " 苏尔特(火法伤+)",
      "background: #f97c7c; color: #000000; padding: 0 1px;",
    ],
    [
      "Peerless",
      "☯无双☯",
      "background: #ffd760; color: #000; border-radius: 2px; vertical-align: baseline; box-shadow: 0 0 0 1px #ffd760;",
    ],
    ["Platinum", "白金"],
    [
      "Reactive",
      "反应装甲(重)",
      "background: #ffa500; color: #000000; padding: 0 1px;",
    ],
    ["Sabatons", "👣马靴"],
    ["Sapphire", "蓝宝石"],
    [
      "Shocking",
      "闪电(电法伤+)",
      "background: #f4f375; color: #000000; padding: 0 1px;",
    ],
    [
      "Superior",
      "上等",
      "background: #fbf9f9; color: #000; border-radius: 2px; vertical-align: baseline; box-shadow: 0 0 0 1px #fbf9f9;",
    ],
    ["Titanium", "钛"],
    [
      "Ultimate",
      "𖣔至尊𖣔",
      "background: #f98048; color: #000; border-radius: 2px; vertical-align: baseline; box-shadow: 0 0 0 1px #ffd760;",
    ],
    ["adorned", " 装饰"],
    [
      "Average",
      "中等",
      "background: #dfdfdf; color: #000; border-radius: 2px; vertical-align: baseline; box-shadow: 0 0 0 1px #dfdfdf;",
    ],
    ["Buckler", "🛡️小圆盾(副)"],
    ["Charged", "充能的(施速+)"],
    ["Cuirass", "🥼胸甲"],
    [
      "Demonic",
      "恶魔(暗法伤+)",
      "background: #000000; color: #ffffff; padding: 0 1px;",
    ],
    ["Diamond", "金刚石"],
    ["Emerald", "祖母绿"],
    ["Greaves", "🦵护胫"],
    ["Katalox", "铁木(圣暗熟练/法伤+)"],
    [
      "Leather",
      "皮革(轻)",
      "background: #666666; color: #FFFFFF; padding: 0 1px;",
    ],
    ["Mithril", "秘银的(负重-)"],
    [
      "Radiant",
      "✪魔光的✪(法伤+)",
      "background: #ffffff; color: #000000; padding: 0 1px;",
    ],
    ["Redwood", "红木(元素熟练/法伤+)"],
    ["trimmed", " 镶边"],
    [
      "Arctic",
      "极寒(冰法伤+)",
      "background: #94c2f5; color: #000000; padding: 0 1px;",
    ],
    ["Astral", "五芒星"],
    ["Bronze", "铜"],
    [
      "Cobalt",
      "钴石的(冰抗+)",
      "background: #a0f4f4; color: #000000; padding: 0 1px;",
    ],
    [
      "Cotton",
      "棉布(布)",
      "background: #FFFFFF; color: #000000; padding: 0 1px;",
    ],
    ["Dagger", "🗡️匕首(单)"],
    [
      "Flimsy",
      "脆弱",
      "background: #848482; color: #000; border-radius: 2px; vertical-align: baseline; box-shadow: 0 0 0 1px #848482;",
    ],
    ["Frugal", "节能的(魔耗-)"],
    ["Gloves", "🤲手套"],
    ["Helmet", "🪖头盔"],
    ["Katana", "※太刀(双)"],
    [
      "Kevlar",
      "凯夫拉(轻)",
      "background: #666666; color: #FFFFFF; padding: 0 1px;",
    ],
    ["Mystic", "神秘的(法爆伤+)"],
    ["Rapier", "🤺西洋剑(单)"],
    [
      "Savage",
      "残暴的(攻暴伤+)",
      "color: red; background: transparent; padding: 0 1px;",
    ],
    ["Scythe", "𖤠镰刀(双)"],
    ["Silver", "银"],
    ["tipped", " 前端"],
    ["Willow", "柳木(减益熟练+暗法伤+)"],
    [
      "Zircon",
      "锆石的(圣抗+)",
      "background: #ffffff; color: #5c5a5a; padding: 0 1px;",
    ],
    ["Agile", "俊敏的(攻速+)"],
    [
      "Amber",
      "琥珀的(电抗+)",
      "background: #ffff00; color: #9f9f16; padding: 0 1px;",
    ],
    ["Boots", "👣靴子"],
    [
      "Chain",
      "锁链(重)",
      "background: #000000; color: #FFFFFF; padding: 0 1px;",
    ],
    [
      "Crude",
      "粗糙",
      "background: #acacac; color: #000; border-radius: 2px; vertical-align: baseline; box-shadow: 0 0 0 1px #acacac;",
    ],
    ["Ebony", "乌木(元素圣暗熟练/法伤+)"],
    ["Estoc", "🤺刺剑(双)"],
    [
      "Fiery",
      "灼热(火法伤+)",
      "background: #f97c7c; color: #000000; padding: 0 1px;",
    ],
    ["Pants", "🦵裤子"],
    [
      "Phase",
      "相位(布)",
      "background: #ffa500; color: #000000; padding: 0 1px;",
    ],
    [
      "Plate",
      "板甲(重)",
      "background: #000000; color: #FFFFFF; padding: 0 1px;",
    ],
    [
      "Power",
      "动力(重)",
      "background: #ffa500; color: #000000; padding: 0 1px;",
    ],
    ["Prism", "光棱"],
    [
      "Shade",
      "暗影(轻)",
      "background: #ffa500; color: #000000; padding: 0 1px;",
    ],
    ["Shoes", "👣鞋子"],
    ["Staff", "🧙‍♂️法杖(双)"],
    ["Steel", "钢"],
    ["Club", "🦯棍(单)"],
    [
      "Fair",
      "普通",
      "background: #c1c1c1; color: #000; border-radius: 2px; vertical-align: baseline; box-shadow: 0 0 0 1px #c1c1c1;",
    ],
    [
      "Fine",
      "优质",
      "background: #b9ffb9; color: #000; border-radius: 2px; vertical-align: baseline; box-shadow: 0 0 0 1px #b9ffb9;",
    ],
    ["Gold", "金"],
    ["Iron", "铁"],
    [
      "Jade",
      "翡翠的(风抗+)",
      "background: #b1f9b1; color: #000000; padding: 0 1px;",
    ],
    ["Mace", "🔨战锤(双)"],
    [
      "Onyx",
      "缟玛瑙(暗抗+)",
      "background: #cccccc; color: #000000; padding: 0 1px;",
    ],
    ["Robe", "🥼长袍"],
    [
      "Ruby",
      "红宝石(火抗+)",
      "background: #ffa6a6; color: #000000; padding: 0 1px;",
    ],
    [
      "Silk",
      "丝绸(布)",
      "background: #FFFFFF; color: #000000; padding: 0 1px;",
    ],
    ["Axe", "🪓斧(单)"],
    ["Cap", "🪖帽 "],
    ["Oak", "橡木(增益熟练+圣法伤+)"],
  ];

  const STATS = {
    Strength: "力量",
    Dexterity: "灵巧",
    Agility: "敏捷",
    Endurance: "体质",
    Intelligence: "智力",
    Wisdom: "智慧",
    "Attack Damage": "攻击伤害",
    "Attack Accuracy": "攻击命中值",
    "Attack Critical": "攻击暴击率",
    "Attack Speed": "攻击速度",
    "Attack Crit Damage": "攻击暴击伤害",
    "Attack Critical Damage": "攻击暴击伤害",
    "Magic Damage": "魔法伤害",
    "Magic Accuracy": "魔法命中值",
    "Magic Critical": "魔法暴击率",
    "Magic Crit Damage": "魔法暴击伤害",
    "Spell Damage": "法术伤害加成",
    "Casting Speed": "施法速度",
    "Mana Conservation": "魔力消耗降低",
    "Physical Mitigation": "物理减伤",
    "Magical Mitigation": "魔法减伤",
    "Damage Mitigation": "属性减伤",
    "Spell Damage Bonus": "魔法伤害加成",
    "Crushing Mitigation": "打击减伤",
    "Piercing Mitigation": "刺击减伤",
    "Slashing Mitigation": "斩击减伤",
    "Fire Mitigation": "火焰抗性",
    "Cold Mitigation": "冰霜抗性",
    "Elec Mitigation": "闪电抗性",
    "Wind Mitigation": "疾风抗性",
    "Holy Mitigation": "神圣抗性",
    "Dark Mitigation": "黑暗抗性",
    "Void Mitigation": "虚空抗性",
    "Fire Damage": "火焰伤害",
    "Cold Damage": "寒冰伤害",
    "Elec Damage": "闪电伤害",
    "Wind Damage": "疾风伤害",
    "Holy Damage": "神圣伤害",
    "Dark Damage": "黑暗伤害",
    "Void Damage": "虚空伤害",
    "Crushing Damage": "打击伤害",
    "Piercing Damage": "刺击伤害",
    "Slashing Damage": "斩击伤害",
    Block: "格挡值",
    "Block Chance": "格挡值",
    Parry: "招架值",
    "Parry Chance": "招架值",
    Evade: "回避值",
    "Evade Chance": "回避值",
    Resist: "抵抗值",
    "Counter-Resist": "反抵抗率",
    "Counter-resist": "反抵抗率",
    "Counter-parry": "反招架值",
    "Counter-Parry": "反招架值",
    "Spell Crit": "法术暴击率",
    "Lasts for": "持续",
    DOT: "持续伤害比例",
    "Ether Theft": "魔力回流",
    Unassigned: "未确定",
    "Salvaged - Repair Required": "已分解 - 需要完全修理",
    "Upgrades and Enchantments": "强化与附魔",
    Fire: "火焰",
    Cold: "冰霜",
    Elec: "闪电",
    Wind: "疾风",
    Holy: "神圣",
    Dark: "黑暗",
    Void: "虚空",
    Crushing: "打击",
    Piercing: "刺击",
    Slashing: "斩击",
    Burden: "负重",
    Interference: "干涉",
    "HP Bonus": "生命加成",
    "MP Bonus": "魔力加成",
    "SP Bonus": "灵力加成",
    Elemental: "元素魔法",
    Divine: "神圣魔法",
    Forbidden: "黑暗魔法",
    Deprecating: "减益魔法",
    Supportive: "增益魔法",
    Proficiency: "熟练度加成",
    roll: "投点",
    Roll: "投点",
    "Average roll": "平均投点",
    "平均 Roll": "平均投点",
  };


  const PANEL = [
    ["One-handed Weapon", "单手武器"],
    ["Two-handed Weapon", "双手武器"],
    ["One-Handed Weapon", "单手武器"],
    ["Two-Handed Weapon", "双手武器"],
    ["One-Handed", "单手武器"],
    ["Two-Handed", "双手武器"],
    ["Cloth Armor", "布甲"],
    ["Light Armor", "轻甲"],
    ["Heavy Armor", "重甲"],
    ["Primary Attributes", "主属性"],
    ["Current Owner", "现持有人"],
    ["Dropped by", "掉落于"],
    ["Equipment Shop", "装备店"],
    ["Rewarded as a Clear Bonus to", "作为竞技场通关奖励给予"],
    ["Transferred from Isekai by", "从异世界中转移获得"],
    ["Rewarded from Snowflake's Shrine to", "作为雪花祭坛献祭奖励给予"],
    ["Penetrated Armor", "穿甲"],
    ["Bleeding Wound", "流血"],
    ["Siphon Spirit", "灵力吸取"],
    ["Siphon Magic", "魔力吸取"],
    ["Siphon Health", "生命吸取"],
    ["Elemental Strike", "属性打击"],
    ["Fire Strike", "火焰打击"],
    ["Cold Strike", "冰霜打击"],
    ["Lightning Strike", "闪电打击"],
    ["Elec Strike", "闪电打击"],
    ["Wind Strike", "疾风打击"],
    ["Holy Strike", "神圣打击"],
    ["Dark Strike", "黑暗打击"],
    ["Void Strike", "虚空打击"],
    ["Condition:", "耐久度:"],
    ["Interference:", "干涉:"],
    ["Untradeable", "不可交易"],
    ["Tradeable", "可交易"],
    ["Soulbound", "灵魂绑定"],
    ["Ether Tap", "魔力回流"],
    ["Burden:", "负重:"],
    ["Energy:", "电量:"],
    ["Stunned", "眩晕"],
    ["Level ", "等级 "],
    ["Tier", "阶级"],
    ["chance", "几率"],
    [" turns", " 回合"],
    [" turn", " 回合"],
    ["Shield", "盾牌"],
    ["Cloth", "布甲"],
    ["Light", "轻甲"],
    ["Heavy", "重甲"],
    ["Staff", "🧙‍♂️法杖(双)"],
    ["None", "无"],
  ];

  const NAME_RE = /^(?:Flimsy|Crude|Fair|Average|Superior|Fine|Exquisite|Magnificent|Legendary|Peerless|Ultimate)\s+\S/;

  function escapeRegExp(value) {
    return value.replace(/[\\^$*+?.()|[\]{}]/g, "\\$&");
  }

  const equipMap = new Map(
    EQ_PHRASES.map((row) => [row[0], { zh: row[1], style: row[2] || "" }]),
  );
  const panelMap = new Map(PANEL);
  for (const [key, value] of Object.entries(STATS)) {
    if (!panelMap.has(key)) panelMap.set(key, value);
  }

  function equipPattern(keys) {
    return keys
      .slice()
      .sort((a, b) => b.length - a.length || a.localeCompare(b))
      .map((key) => {
        const escaped = escapeRegExp(key);
        return /^\w+$/.test(key) ? "\\b" + escaped + "\\b" : escaped;
      })
      .join("|");
  }

  const styledKeys = [];
  const plainKeys = [];
  for (const [key, entry] of equipMap) {
    if (entry.style) styledKeys.push(key);
    else plainKeys.push(key);
  }
  const equipStyleRe = styledKeys.length ? new RegExp(equipPattern(styledKeys), "g") : null;
  const equipPlainRe = plainKeys.length ? new RegExp(equipPattern(plainKeys), "g") : null;
  const equipAllRe = new RegExp(equipPattern([...equipMap.keys()]), "g");
  const panelRe = new RegExp(equipPattern([...panelMap.keys()]), "g");

  function collectMatches(regex, text) {
    if (!regex || !text) return [];
    regex.lastIndex = 0;
    const matches = [];
    let found;
    while ((found = regex.exec(text))) {
      matches.push({ start: found.index, end: found.index + found[0].length, key: found[0] });
      if (!found[0]) regex.lastIndex += 1;
    }
    return matches;
  }

  function isEquipName(text) {
    const trimmed = String(text || "").trim();
    if (trimmed.length < 6 || trimmed.length > 140) return false;
    if (/[.!?;::。!?]/.test(trimmed)) return false;
    const summary = /\bLv\.?\s*\d+\s*$/.test(trimmed);
    if (!NAME_RE.test(trimmed) && !summary) return false;
    equipAllRe.lastIndex = 0;
    const rest = trimmed
      .replace(equipAllRe, " ")
      .replace(/\b(?:of|the)\b/gi, " ")
      .replace(/\bLv\.?\s*/gi, " ")
      .replace(/[\d.\s+\-()]/g, "");
    equipAllRe.lastIndex = 0;
    return rest.length === 0;
  }

  function paintName(el, source) {
    const styled = collectMatches(equipStyleRe, source);
    const frag = document.createDocumentFragment();
    let prevSpace = false;
    function appendPiece(value, style) {
      if (!value) return;
      if (prevSpace) value = value.replace(/^[ \t]+/, "");
      if (!value) return;
      if (style) {
        const mark = document.createElement("mark");
        mark.className = "hv-eq";
        mark.style.cssText = "background:transparent;color:inherit;padding:0;margin:0;" + style;
        if (value.includes("<")) mark.innerHTML = value;
        else mark.textContent = value;
        frag.appendChild(mark);
      } else {
        frag.appendChild(document.createTextNode(value));
      }
      prevSpace = /\s$/.test(value);
    }
    function appendPlain(value) {
      const lv = value.match(/^(.*?)(\s*Lv\.?\s*\d+)(.*)$/);
      if (lv) {
        appendPlain(lv[1]);
        appendPiece(" 等级" + lv[2].replace(/\D/g, ""), "");
        appendPlain(lv[3]);
        return;
      }
      const matches = collectMatches(equipPlainRe, value);
      if (!matches.length) {
        appendPiece(value, "");
        return;
      }
      let cursor = 0;
      for (const match of matches) {
        appendPiece(value.slice(cursor, match.start), "");
        const entry = equipMap.get(match.key);
        appendPiece(entry ? entry.zh : match.key, "");
        cursor = match.end;
      }
      appendPiece(value.slice(cursor), "");
    }
    if (!styled.length) appendPlain(source);
    else {
      let cursor = 0;
      for (const match of styled) {
        appendPlain(source.slice(cursor, match.start));
        const entry = equipMap.get(match.key);
        appendPiece(entry ? entry.zh : match.key, entry && entry.style);
        cursor = match.end;
      }
      appendPlain(source.slice(cursor));
    }
    el.replaceChildren(frag);
  }

  function applyPanel(text) {
    const structured = text
      .replace(/Dropped[ \u00a0]+by[ \u00a0]+(.+?)[ \u00a0]+for/g, (match, source) => {
        const name = source.replace(/\u00a0/g, " ").replace(/ +/g, " ").trim();
        return `掉落于 "${name}",获得者`;
      })
      .replace(/on[ \u00a0]+(\d{4}-\d{2}-\d{2})/g, (match, date) => `掉落时间: ${date} `);
    const normalized = structured.replace(/\u00a0/g, " ");
    let result = "";
    let last = 0;
    panelRe.lastIndex = 0;
    let match;
    while ((match = panelRe.exec(normalized)) !== null) {
      result += structured.slice(last, match.index);
      result += panelMap.get(match[0]) || match[0];
      last = match.index + match[0].length;
      if (!match[0]) panelRe.lastIndex += 1;
    }
    return result + structured.slice(last);
  }

  function translateCard(root) {
    const texts = [];
    const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
    while (walker.nextNode()) texts.push(walker.currentNode);
    for (const node of texts) {
      const parent = node.parentElement;
      if (!parent || parent.closest("mark.hv-eq") || parent.children.length > 0) continue;
      if (!isEquipName(parent.textContent)) continue;
      paintName(parent, parent.textContent);
    }
    const rest = [];
    const walker2 = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
    while (walker2.nextNode()) rest.push(walker2.currentNode);
    for (const node of rest) {
      if (!node.parentElement || node.parentElement.closest("mark.hv-eq")) continue;
      const next = applyPanel(node.data);
      if (next !== node.data) node.data = next;
    }
  }

  const TIP_CSS = `
    :host {
      all: initial;
      position: fixed;
      z-index: 2147483646;
      display: block;
      width: max-content;
      pointer-events: none;
    }
    :host([hidden]) { display: none !important; }
    .card {
      box-sizing: border-box;
      width: 420px;
      max-height: 80vh;
      overflow: visible;
      padding: 4px 0;
      border: 1px solid #5c0d11;
      background: #e3e0d1;
      color: #5c0d11;
      font-family: verdana, sans-serif;
      font-size: 8pt;
      line-height: normal;
      text-align: center;
    }
    p { margin: 0; }
    a:link, a:visited, a:active { color: #5c0d11; }
    .showequip > div:first-child a { color: inherit; text-decoration: none; }
    .showequip > div { margin: 3px auto 0; padding: 1px; }
    .showequip > div:first-child,
    .eq span, .eqt, .eqr, .eqc { font-weight: 700; }
    .showequip > div:first-child {
      border-bottom: 1px solid #a47c78;
      font-size: 10pt;
      padding: 2px 0 4px;
    }
    .eq > div { margin: 1px auto; padding: 1px; text-align: center; min-height: 17px; }
    .eqt, .eqr { font-size: 110%; }
    .ex, .ep { display: grid; gap: 2px 5px; justify-items: center; }
    .ex {
      grid-template-columns: repeat(2, 1fr);
      border-top: 1px solid #a47c78;
      text-align: right !important;
    }
    .ex > div { width: 165px; height: 18px; }
    .ex > div > div:nth-child(1) { float: left; width: 99px; margin: 0; padding: 2px; white-space: nowrap; }
    .ex > div > div:nth-child(2) { float: left; width: 45px; margin: 0; padding: 2px 0 2px 2px; }
    .ex > div > div:nth-child(3) { float: left; width: 6px; margin: 0; padding: 2px; }
    .ep > div { width: 115px; height: 18px; }
    .ep > div:first-child {
      font-weight: 700;
      margin: 4px auto 0 !important;
      width: 320px !important;
      grid-column: 1 / -1;
    }
    .ep > div > div:nth-child(1) { float: left; width: 60px; margin: 0; padding: 2px; text-align: right; white-space: nowrap; }
    .ep > div > div:nth-child(2) { float: left; width: 45px; margin: 0; padding: 2px 0 2px 2px; text-align: left; }
    .ep1 { grid-template-columns: repeat(1, 1fr); }
    .ep2 { grid-template-columns: repeat(2, 1fr); }
    .ep3 { grid-template-columns: repeat(3, 1fr); }
    .tip-msg { padding: 10px; font-weight: 700; }
    mark.hv-eq { background: transparent; color: inherit; padding: 0; margin: 0; font: inherit; }
    .hv-pct { margin-top: 3px; padding: 2px 0; border-top: 1px solid #a47c78; font-weight: 700; }
    .hv-pct-mode { font-size: 10px; }
    .hv-pct-high, .hv-pct-gain { color: #0ab844; }
    .hv-pct-low { color: #f03939; }
    .hv-pct-mid { color: #1d3271; }
  `;

  function canonicalEquipUrl(href) {
    let url;
    try {
      url = new URL(href, location.href);
    } catch (error) {
      return "";
    }
    if (!/^(?:alt\.)?hentaiverse\.org$/i.test(url.hostname)) return "";
    const match = /^\/(isekai\/)?equip\/([1-9]\d{0,11})\/([a-f0-9]{10})\/?$/i.exec(url.pathname);
    if (!match) return "";
    const host = /^alt\.hentaiverse\.org$/i.test(url.hostname) ? "alt.hentaiverse.org" : "hentaiverse.org";
    return "https://" + host + "/" + (match[1] || "") + "equip/" + match[2] + "/" + match[3].toLowerCase();
  }

  function openEquipWindow(url) {
    const width = 450;
    const height = 520;
    const screen = pageWindow.screen;
    const features = [
      "toolbar=0",
      "scrollbars=0",
      "location=0",
      "statusbar=0",
      "menubar=0",
      "resizable=0",
      "width=" + width,
      "height=" + height,
      "left=" + (screen.width - width) / 2,
      "top=" + (screen.height - height) / 2,
    ].join(",");
    const name = "_pu" + String(Math.random()).replace(/0\./, "");
    return pageWindow.open(url, name, features);
  }

  function absoluteUrl(value, base) {
    try {
      return new URL(value, base).href;
    } catch (error) {
      return value;
    }
  }

  function parseEquipHtml(html, url) {
    const doc = new DOMParser().parseFromString(html, "text/html");
    const content = doc.querySelector(".showequip");
    if (!content) {
      const message = (doc.body && doc.body.textContent ? doc.body.textContent : "").replace(/\s+/g, " ").trim();
      if (/logged on/i.test(message)) throw new Error("需要登录 Hentaiverse 才能预览装备");
      throw new Error(message || "没有装备信息");
    }
    content.querySelectorAll("script").forEach((el) => el.remove());
    content.querySelectorAll(".ep").forEach((ep) => {
      if (/\bep[123]\b/.test(ep.className)) return;
      const cols = Math.min(3, Math.max(1, ep.children.length - 1));
      ep.classList.add("ep" + cols);
    });
    content.querySelectorAll("[src]").forEach((el) => {
      const src = el.getAttribute("src");
      if (src) el.setAttribute("src", absoluteUrl(src, url));
    });
    const title = content.firstElementChild;
    if (title && !title.querySelector("a")) {
      const link = doc.createElement("a");
      link.href = url;
      while (title.firstChild) link.appendChild(title.firstChild);
      title.appendChild(link);
    }
    return document.adoptNode(content);
  }

  class HvEquipTipElement extends HTMLElement {
    constructor() {
      super();
      const shadow = this.attachShadow({ mode: "open" });
      const style = document.createElement("style");
      style.textContent = TIP_CSS;
      this.body = document.createElement("div");
      this.body.className = "card";
      shadow.append(style, this.body);
      this.body.addEventListener("click", (event) => {
        const node = event.target;
        const anchor = node && node.closest ? node.closest("a[href]") : null;
        if (!anchor) return;
        const equip = canonicalEquipUrl(anchor.getAttribute("href") || anchor.href || "");
        if (!equip) return;
        event.preventDefault();
        openEquipWindow(equip);
      });
    }
  }

  if (!customElements.get("hv-equip-tip")) {
    customElements.define("hv-equip-tip", HvEquipTipElement);
  }

  const cache = new Map();
  let translating = false;
  let showPercent = true;
  let showMaxForge = false;
  try {
    showPercent = localStorage.getItem("hvEquipTipPercent") !== "false";
  } catch (error) {}
  let options = { delay: 200 };
  let bound = false;
  let tip = null;
  let currentLink = null;
  let currentUrl = "";
  let showTimer = 0;
  let hideTimer = 0;
  let request = null;
  let pointerX = 0;
  let pointerY = 0;

  function ensureTip() {
    if (tip && tip.isConnected) return tip;
    tip = document.createElement("hv-equip-tip");
    tip.hidden = true;
    tip.style.position = "fixed";
    tip.style.zIndex = "2147483646";
    tip.style.pointerEvents = "none";
    tip.style.width = "max-content";
    document.body.appendChild(tip);
    return tip;
  }

  function hideTip() {
    if (!tip) return;
    tip.hidden = true;
    tip.body.replaceChildren();
  }

  function placeTip() {
    const box = ensureTip();
    const gap = 16;
    const margin = 8;
    const tipRect = box.getBoundingClientRect();
    const width = tipRect.width || 420;
    const height = tipRect.height || 0;
    let left = pointerX + gap;
    let top = pointerY + gap;
    if (left + width > window.innerWidth - margin) {
      left = pointerX - width - gap;
    }
    if (left < margin) left = margin;
    if (height && top + height > window.innerHeight - margin) {
      top = pointerY - height - gap;
    }
    if (top < margin) top = margin;
    box.style.left = left + "px";
    box.style.top = top + "px";
  }

  function showHtml(link, html) {
    if (currentLink !== link) return;
    const box = ensureTip();
    box.body.replaceChildren();
    box.body.insertAdjacentHTML("afterbegin", html);
    box.hidden = false;
    placeTip();
  }

  function showMessage(link, message) {
    if (currentLink !== link) return;
    const box = ensureTip();
    box.body.replaceChildren();
    const line = document.createElement("div");
    line.className = "tip-msg";
    line.textContent = message;
    box.body.appendChild(line);
    box.hidden = false;
    placeTip();
  }

  function abortRequest() {
    if (!request) return;
    try { request.abort(); } catch (error) {}
    request = null;
  }

  function insideTip(node) {
    if (!tip || !node) return false;
    if (node === tip || tip.contains(node)) return true;
    const rootNode = node.getRootNode ? node.getRootNode() : null;
    return rootNode === tip.shadowRoot;
  }

  const RANGE_QUALITY = {
    Peerless: { range: [200, 200], cap: 60 },
    Legendary: { range: [170, 200], cap: 50 },
    Magnificent: { range: [150, 180], cap: 40 },
    Exquisite: { range: [120, 160], cap: 20 },
    Superior: { range: [90, 130], cap: 20 },
    Average: { range: [60, 100], cap: 20 },
    Fair: { range: [30, 70], cap: 20 },
    Crude: { range: [0, 40], cap: 20 },
  };
  const RANGE_CHARM = new Set(["HP Bonus", "MP Bonus", "Counter-parry"]);
  const RANGE_VALUE = /^(\d+(?:\.\d+)?)(?:\s+(\S.*))?$/;
  const RANGE_BASE = /Base:\s*([+-]?[\d.]+)/i;
  const RANGE_TIER = /Tier\s+(\d+)\s*\/\s*(\d+)\s*\/\s*(\d+)/i;
  const RANGE_DAMAGE = /^(?:Magic|Attack|Void|Crushing|Piercing|Slashing) Damage/;

  function rangeTone(percent, high, low) {
    if (percent >= high) return "hv-pct-high";
    if (percent <= low) return "hv-pct-low";
    return "hv-pct-mid";
  }

  function parseRanges(container) {
    const eq = container.querySelector(".eq");
    const eqt = container.querySelector(".eqt");
    if (!eq || !eqt) return null;
    const title = (container.firstElementChild ? container.firstElementChild.textContent : "").trim();
    const found = Object.entries(RANGE_QUALITY).find(([key]) => title.startsWith(key));
    if (!found) return null;
    const [quality, config] = found;
    const stats = [];
    const rows = [
      ...eq.querySelectorAll(".ex > div, .ep > div"),
      ...Array.from(eq.querySelectorAll('[title^="Base: "]')).filter((el) => !el.closest(".ex, .ep")),
    ];
    for (const row of rows) {
      const span = row.querySelector("span");
      if (!span) continue;
      const match = RANGE_VALUE.exec(span.textContent.trim());
      if (!match || !match[1]) continue;
      const name = match[2] != null ? match[2] : (row.firstElementChild ? row.firstElementChild.textContent.trim() : "");
      if (!name) continue;
      const baseTitle = row.getAttribute("title") || "";
      const baseMatch = baseTitle.match(RANGE_BASE);
      const prefixNode = span.previousSibling;
      let suffixNode = span.parentNode ? span.parentNode.nextSibling : null;
      while (suffixNode && suffixNode.nodeType === 3 && !suffixNode.textContent.trim()) {
        suffixNode = suffixNode.nextSibling;
      }
      const unitNode = span.nextSibling && span.nextSibling.nodeType === 3 ? span.nextSibling : null;
      stats.push({
        title: name,
        val: Number.parseFloat(match[1]),
        valStr: match[1],
        base: baseMatch ? Number.parseFloat(baseMatch[1]) : null,
        rate: RANGE_DAMAGE.test(name) ? 2 : 1,
        prefix: prefixNode && prefixNode.nodeType === 3 ? prefixNode.textContent : "",
        suffix: suffixNode ? suffixNode.textContent : "",
        typeText: match[2] != null ? match[2] : null,
        el: span,
        prefixNode: prefixNode,
        suffixNode: suffixNode,
        unitNode: unitNode,
      });
    }
    const tierMatch = RANGE_TIER.exec(eqt.textContent);
    const levelMatch = /Level (\d+)/i.exec(eqt.textContent);
    const equip = tierMatch
      ? {
        type: "Soulbound",
        quality: quality,
        range: config.range,
        tierUp: +tierMatch[1],
        tierIw: +tierMatch[2],
        tierMax: +tierMatch[3],
        forgeMax: +tierMatch[3] * 2,
        eqt: eqt,
        stats: stats,
      }
      : {
        type: "Tradeable",
        quality: quality,
        range: config.range,
        forgeMax: config.cap,
        level: levelMatch ? +levelMatch[1] : 0,
        stats: stats,
      };
    equip.container = container;
    return equip;
  }

  function renderRanges(equip) {
    if (equip.type === "Soulbound") {
      const up = equip.tierUp;
      const iw = equip.tierIw;
      const maxTier = equip.tierMax;
      if (showMaxForge && (maxTier - up > 0 || maxTier - iw > 0)) {
        const diff = (current) => (
          maxTier - current > 0
            ? current + '<span class="hv-pct-gain">+' + (maxTier - current) + "</span>"
            : String(maxTier)
        );
        equip.eqt.innerHTML = "Tier " + diff(up) + " / " + diff(iw) + " / " + maxTier;
      } else {
        equip.eqt.textContent = "Tier " + up + " / " + iw + " / " + maxTier;
      }
    }
    let total = 0;
    let count = 0;
    const min = equip.range[0];
    const max = equip.range[1];
    const forged = equip.type === "Soulbound" ? equip.tierUp + equip.tierIw : 0;
    for (const stat of equip.stats) {
      let percent = null;
      if (stat.base !== null) {
        const span = max - min || 30;
        percent = Number.parseFloat((((stat.base - (max - span)) / span) * 100).toFixed(1));
        total += percent;
        count += 1;
      }
      const forgeMode = showMaxForge && equip.forgeMax > forged && !RANGE_CHARM.has(stat.title);
      const percentMode = showPercent && !forgeMode && percent !== null;
      const hideAround = forgeMode || percentMode;
      if (stat.prefixNode && stat.prefixNode.nodeType === 3) {
        stat.prefixNode.textContent = hideAround ? "" : stat.prefix;
      }
      const suffixIsUnit = stat.suffixNode && /^\s*x\s*$/i.test(stat.suffixNode.textContent || "");
      if (stat.suffixNode && !stat.typeText && (stat.suffixNode.nodeType === 3 || suffixIsUnit)) {
        stat.suffixNode.textContent = hideAround ? "" : stat.suffix;
      }
      if (stat.unitNode && hideAround && /^\s*x\s*$/i.test(stat.unitNode.textContent)) {
        stat.unitNode.textContent = "";
      }
      const typeText = stat.typeText && !(hideAround && stat.typeText === "x") ? " " + stat.typeText : "";
      stat.el.classList.remove("hv-pct-high", "hv-pct-low", "hv-pct-mid");
      if (forgeMode) {
        const ratio = stat.rate / 100;
        const maxVal = stat.val * (1 + equip.forgeMax * ratio) / (1 + forged * ratio);
        const gain = +(maxVal - stat.val).toFixed(2);
        stat.el.innerHTML = stat.valStr + '<span class="hv-pct-gain">+' + gain + "</span>" + typeText;
      } else if (percentMode) {
        stat.el.textContent = percent + "%" + typeText;
        stat.el.classList.add(rangeTone(percent, 70, 30));
      } else {
        stat.el.textContent = stat.valStr + typeText;
      }
    }
    const footer = document.createElement("div");
    footer.className = "hv-pct";
    if (count > 0) {
      const avg = Number.parseFloat((total / count).toFixed(1));
      const avgLine = document.createElement("div");
      avgLine.className = rangeTone(avg, 60, 40);
      const modeLine = document.createElement("div");
      modeLine.className = "hv-pct-mode";
      if (translating) {
        const mode = showMaxForge
          ? "满锻"
          : showPercent
            ? "百分比(按F切换数值,W切换满锻)"
            : "数值(F切换百分比,W切换满锻)";
        avgLine.textContent = "优秀度: " + avg + "%";
        modeLine.textContent = "模式: " + mode;
      } else {
        const mode = showMaxForge ? "Max Forged" : showPercent ? "Percentage" : "Value";
        avgLine.textContent = "Avg: " + avg + "%";
        modeLine.textContent = "Mode: " + mode;
      }
      footer.append(avgLine, modeLine);
    }
    equip.container.appendChild(footer);
  }

  function applyRanges(container) {
    const equip = parseRanges(container);
    if (equip) renderRanges(equip);
  }

  function refreshOpenTip() {
    if (!currentLink || !currentUrl || !tip || tip.hidden) return;
    const entry = cache.get(currentUrl);
    if (entry) present(currentLink, entry);
  }

  function present(link, entry) {
    const holder = document.createElement("div");
    holder.innerHTML = entry.raw;
    const node = holder.firstElementChild;
    if (node) {
      applyRanges(node);
      if (translating) translateCard(node);
    }
    showHtml(link, node ? node.outerHTML : entry.raw);
  }

  function renderTip(link) {
    const url = canonicalEquipUrl(link.getAttribute("href") || link.href || "");
    if (!url) return;
    if (insideTip(link)) return;
    window.clearTimeout(showTimer);
    window.clearTimeout(hideTimer);
    abortRequest();
    currentLink = link;
    currentUrl = url;
    const cached = cache.get(url);
    if (cached) {
      present(link, cached);
      return;
    }
    hideTip();
    const delay = Number(options.delay) >= 0 ? Number(options.delay) : 200;
    showTimer = window.setTimeout(() => {
      if (currentLink !== link || typeof GM_xmlhttpRequest !== "function") return;
      request = GM_xmlhttpRequest({
        method: "GET",
        url,
        timeout: 15000,
        onload: (response) => {
          request = null;
          if (currentLink !== link) return;
          if (response.status < 200 || response.status >= 300) {
            showMessage(link, "装备页打开失败(" + response.status + ")");
            return;
          }
          try {
            const node = parseEquipHtml(response.responseText, url);
            const entry = { raw: node.outerHTML, zh: "" };
            cache.set(url, entry);
            present(link, entry);
          } catch (error) {
            showMessage(link, error && error.message ? error.message : "没有装备信息");
          }
        },
        onerror: () => {
          request = null;
          showMessage(link, "装备页打开失败");
        },
        ontimeout: () => {
          request = null;
          showMessage(link, "装备页打开超时");
        },
        onabort: () => {
          request = null;
        },
      });
    }, delay);
  }

  function eventElement(event) {
    const path = typeof event.composedPath === "function" ? event.composedPath() : [];
    const node = path.length ? path[0] : event.target;
    if (!node) return null;
    return node.nodeType === 1 ? node : node.parentElement;
  }

  function isTranslate(value) {
    if (arguments.length === 0) return translating;
    translating = !!value;
    refreshOpenTip();
    return translating;
  }

  function attach(next) {
    if (next && next.delay != null) options.delay = Number(next.delay);
    if (bound) return;
    bound = true;
    document.addEventListener("pointerover", (event) => {
      if (event.pointerType === "touch") return;
      const el = eventElement(event);
      if (!el) return;
      if (insideTip(el)) {
        window.clearTimeout(hideTimer);
        return;
      }
      const link = el.closest ? el.closest("a[href]") : null;
      if (!link || !canonicalEquipUrl(link.getAttribute("href") || link.href || "")) return;
      if (link === currentLink && tip && !tip.hidden) {
        window.clearTimeout(hideTimer);
        return;
      }
      pointerX = event.clientX;
      pointerY = event.clientY;
      renderTip(link);
    }, true);
    document.addEventListener("keydown", (event) => {
      if (event.repeat || event.ctrlKey || event.altKey || event.metaKey || event.isComposing) return;
      if (typeof event.key !== "string") return;
      const key = event.key.toLowerCase();
      if (key !== "f" && key !== "w") return;
      const target = event.target;
      if (target && target.closest && target.closest("input, textarea, select, [contenteditable='true']")) return;
      event.preventDefault();
      if (key === "f") {
        showMaxForge = false;
        showPercent = !showPercent;
        try { localStorage.setItem("hvEquipTipPercent", showPercent ? "true" : "false"); } catch (error) {}
      } else {
        showPercent = false;
        showMaxForge = !showMaxForge;
      }
      refreshOpenTip();
    }, true);
    document.addEventListener("pointerout", (event) => {
      if (!currentLink || !tip || event.pointerType === "touch") return;
      const el = eventElement(event);
      if (!el) return;
      if (!insideTip(el) && !currentLink.contains(el)) return;
      const nextNode = event.relatedTarget;
      if (nextNode && (insideTip(nextNode) || currentLink.contains(nextNode))) return;
      window.clearTimeout(showTimer);
      abortRequest();
      window.clearTimeout(hideTimer);
      hideTimer = window.setTimeout(() => {
        currentLink = null;
        currentUrl = "";
        hideTip();
      }, 200);
    }, true);
  }

  root.HvEquipTip = {
    url: canonicalEquipUrl,
    open: openEquipWindow,
    attach: attach,
    isTranslate: isTranslate,
  };
})(typeof globalThis !== "undefined" ? globalThis : this);