☰

hv-equip-tip

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

بۇ قوليازمىنى بىۋاسىتە قاچىلاشقا بولمايدۇ. بۇ باشقا قوليازمىلارنىڭ ئىشلىتىشى ئۈچۈن تەمىنلەنگەن ئامبار بولۇپ، ئىشلىتىش ئۈچۈن مېتا كۆرسەتمىسىگە قىستۇرىدىغان كود: // @require https://update.sleazyfork.org/scripts/601302/1969559/hv-equip-tip.js

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

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

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

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

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

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==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);