diff --git a/CHANGELOG.md b/CHANGELOG.md index 88a1e53..fbfc97b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ 本文件记录 Iridium 的重要变更。 +## [26.9.5] - 2026-09-13 + +### Fixed +- `!!hat`(1.17+):改用副手缓冲做真正交换,避免复杂 NBT 重建失败导致原头盔消失;副手非空时拒绝并提示 +- `!!share`:展示名优先取 `custom_name` / `display.Name` / translate,不再只显示物品 ID + ## [26.9.4] - 2026-09-13 ### Added diff --git a/README.md b/README.md index d0eb2e4..a1d4f47 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ MCDReforged 插件:物品展示 / 戴头 / 获取头颅 / 游戏内计算器 / | | | |---|---| -| 版本 | **26.9.4** | +| 版本 | **26.9.5** | | 作者 | 云镜之端/SKYMirror | | 许可 | 保留所有权益(All Rights Reserved) | | 前置 | MCDReforged `>= 2.6.0` | diff --git a/iridium/hat.py b/iridium/hat.py index e8e33b9..8702422 100644 --- a/iridium/hat.py +++ b/iridium/hat.py @@ -258,52 +258,64 @@ def _extract_item_payload(payload: str, version: Version) -> Optional[str]: def _do_swap(server: PluginServerInterface, src: CommandSource, player: str, version: Version) -> bool: - if not is_at_least(version, "1.13"): - # Single entitydata dump contains both SelectedItem and head Inventory slot - dump = rcon_query(server, _entitydata_query(player)) - if dump is None: - src.reply(RText(tr("hat_query_failed"), RColor.red)) - return False - hotbar_index = _extract_selected_slot(dump) - hand = _extract_item_payload(dump, version) - if hand is None or _is_air(hand): - src.reply(RText(tr("hand_is_empty"), RColor.red)) - return False - head = _extract_head_item(dump, version) - else: + name = sanitize_name(player) + head_slot = _head_slot(version) + hand_slot = _hand_slot(version, 0) + + # 1.17+: true swap via offhand buffer — no need to rebuild item NBT + if is_at_least(version, "1.17"): hand_raw = rcon_query(server, _selected_query(player, version)) if hand_raw is None: src.reply(RText(tr("hat_query_failed"), RColor.red)) return False - hotbar_index = 0 hand = _extract_item_payload(hand_raw, version) if hand is None or _is_air(hand): src.reply(RText(tr("hand_is_empty"), RColor.red)) return False - head = None - head_raw = rcon_query(server, _head_query(player, version)) - if head_raw is not None: - head = _extract_head_item(head_raw, version) - head_slot = _head_slot(version) - hand_slot = _hand_slot(version, hotbar_index) - name = sanitize_name(player) + off_raw = rcon_query(server, f"data get entity {name} weapon.offhand") + off = None + if off_raw is not None: + off = _extract_item_payload(off_raw, version) + if off and not _is_air(off): + src.reply(RText(tr("hat_offhand_busy"), RColor.red)) + return False - if is_at_least(version, "1.17"): + # 1) save head -> offhand 2) hand -> head 3) offhand -> hand 4) clear offhand + safe_execute( + server, + f"item replace entity {name} weapon.offhand from entity {name} {head_slot}", + ) safe_execute( server, f"item replace entity {name} {head_slot} from entity {name} weapon.mainhand", ) - if head and not _is_air(head): - safe_execute(server, _replace_with(player, hand_slot, head, version)) - else: - safe_execute(server, f"item replace entity {name} {hand_slot} with air") + safe_execute( + server, + f"item replace entity {name} weapon.mainhand from entity {name} weapon.offhand", + ) + safe_execute(server, f"item replace entity {name} weapon.offhand with air") + src.reply(RText(tr("hat_success"), RColor.green)) + return True + + # 1.8–1.16: rebuild via parsed SNBT + dump = rcon_query(server, _entitydata_query(player)) + if dump is None: + src.reply(RText(tr("hat_query_failed"), RColor.red)) + return False + hotbar_index = _extract_selected_slot(dump) + hand_slot = _hand_slot(version, hotbar_index) + hand = _extract_item_payload(dump, version) + if hand is None or _is_air(hand): + src.reply(RText(tr("hand_is_empty"), RColor.red)) + return False + head = _extract_head_item(dump, version) + + safe_execute(server, _replace_with(player, head_slot, hand, version)) + if head and not _is_air(head): + safe_execute(server, _replace_with(player, hand_slot, head, version)) else: - safe_execute(server, _replace_with(player, head_slot, hand, version)) - if head and not _is_air(head): - safe_execute(server, _replace_with(player, hand_slot, head, version)) - else: - safe_execute(server, _replace_with(player, hand_slot, "minecraft:air", version)) + safe_execute(server, _replace_with(player, hand_slot, "minecraft:air", version)) src.reply(RText(tr("hat_success"), RColor.green)) return True diff --git a/iridium/share.py b/iridium/share.py index 1927b86..5040117 100644 --- a/iridium/share.py +++ b/iridium/share.py @@ -63,6 +63,35 @@ def _json_str(value: str) -> str: return json.dumps(value, ensure_ascii=False)[1:-1] +def _extract_display_name(item_snbt: str) -> str: + """Best-effort human-readable name from item SNBT.""" + # JSON text component: "text":"Name" + m = re.search(r'"text"\s*:\s*"([^"]+)"', item_snbt) + if m and m.group(1).strip(): + return _strip_mc_codes(m.group(1).strip()) + # display.Name / custom_name as plain string + m = re.search(r'(?:Name|custom_name)\s*:\s*"([^"]+)"', item_snbt) + if m and m.group(1).strip(): + return _strip_mc_codes(m.group(1).strip()) + m = re.search(r"(?:Name|custom_name)\s*:\s*'([^']+)'", item_snbt) + if m and m.group(1).strip(): + return _strip_mc_codes(m.group(1).strip()) + # translation key fallback (e.g. block.ae2.fluix_covered_cable) + m = re.search(r'"translate"\s*:\s*"([^"]+)"', item_snbt) + if m and m.group(1).strip(): + key = m.group(1).strip() + short = key.rsplit(".", 1)[-1] + return short.replace("_", " ") + id_match = re.search(r'id\s*:\s*"([^"]+)"', item_snbt) + if id_match: + return id_match.group(1) + return "?" + + +def _strip_mc_codes(text: str) -> str: + return re.sub(r"§[0-9a-fk-orA-FK-OR]", "", text) + + def _build_tellraw(player: str, item_snbt: str, version: Version) -> str: # Truncate pathological NBT (huge books etc.) so the command stays valid if len(item_snbt) > MAX_SNBT_LEN: @@ -88,10 +117,7 @@ def _build_tellraw(player: str, item_snbt: str, version: Version) -> str: click = '{"action":"suggest_command","value":"' + _json_str(suggest) + '"}' click_label = _json_str(tr("click_suggest")) - id_match = re.search(r'id\s*:\s*"([^"]+)"', item_snbt) - item_id = id_match.group(1) if id_match else "?" - name_match = re.search(r'"text"\s*:\s*"([^"]+)"', item_snbt) - display = name_match.group(1) if name_match else item_id + display = _extract_display_name(item_snbt) display_js = _json_str(display) json_text = ( diff --git a/lang/en_us.yml b/lang/en_us.yml index a422d91..0077a77 100644 --- a/lang/en_us.yml +++ b/lang/en_us.yml @@ -19,6 +19,7 @@ iridium.hat_success: 'Swapped hand item with head equipment' iridium.hat_unsupported_version: 'Version {0} does not support !!hat (needs Minecraft 1.8+)' iridium.hat_need_rcon: '!!hat requires RCON enabled to read and swap item data' iridium.hat_query_failed: 'Failed to query player item data' +iridium.hat_offhand_busy: 'Off-hand is occupied; clear it before using !!hat' iridium.head_help: 'Get one player head (optional player name, cooldown {0}s)' iridium.head_help_short: 'Get one player head (optional player name)' iridium.head_success: 'You received a head of {0}' diff --git a/lang/zh_cn.yml b/lang/zh_cn.yml index 28af789..fa863e2 100644 --- a/lang/zh_cn.yml +++ b/lang/zh_cn.yml @@ -19,6 +19,7 @@ iridium.hat_success: '已交换手上物品与头部装备' iridium.hat_unsupported_version: '当前版本 {0} 不支持 !!hat(需要 Minecraft 1.8+)' iridium.hat_need_rcon: '!!hat 需要启用 RCON 才能读取并交换物品数据' iridium.hat_query_failed: '读取玩家物品数据失败' +iridium.hat_offhand_busy: '副手有物品,无法安全交换;请先清空副手再使用 !!hat' iridium.head_help: '获取一个玩家头颅(可指定玩家名,冷却 {0} 秒)' iridium.head_help_short: '获取一个玩家头颅(可指定玩家名)' iridium.head_success: '已给予你 {0} 的头颅' diff --git a/mcdreforged.plugin.json b/mcdreforged.plugin.json index 9da4fc1..f24d31f 100644 --- a/mcdreforged.plugin.json +++ b/mcdreforged.plugin.json @@ -1,6 +1,6 @@ { "id": "iridium", - "version": "26.9.4", + "version": "26.9.5", "name": "Iridium", "description": { "zh_cn": "物品展示 / 戴头 / 获取头颅 / 游戏内计算器 / 进服 MOTD",