MCDR plugin: item share, hat, player head, calculator, join MOTD. All rights reserved. Author: 云镜之端/SKYMirror
337 lines
8.8 KiB
Python
337 lines
8.8 KiB
Python
import re
|
|
from typing import Optional, Tuple
|
|
|
|
from mcdreforged.api.all import *
|
|
|
|
from iridium.config import config
|
|
from iridium.utils import (
|
|
check_cooldown,
|
|
check_permission,
|
|
looks_like_item_snbt,
|
|
mark_cooldown,
|
|
parse_selected_item,
|
|
require_player,
|
|
rcon_query,
|
|
safe_execute,
|
|
sanitize_name,
|
|
strip_data_prefix,
|
|
)
|
|
from iridium.version_util import is_at_least, require_version, tr
|
|
|
|
AIR_RE = re.compile(r'id\s*:\s*"(minecraft:)?air"')
|
|
|
|
|
|
def _is_air(snbt: Optional[str]) -> bool:
|
|
if snbt is None:
|
|
return True
|
|
stripped = snbt.strip()
|
|
return stripped in ("{}", "", "[]") or bool(AIR_RE.search(stripped))
|
|
|
|
|
|
def _can_swap(version: Version) -> bool:
|
|
return is_at_least(version, "1.8")
|
|
|
|
|
|
def _entitydata_query(player: str) -> str:
|
|
return f"entitydata {sanitize_name(player)} {{}}"
|
|
|
|
|
|
def _selected_query(player: str, version: Version) -> str:
|
|
name = sanitize_name(player)
|
|
if is_at_least(version, "1.13"):
|
|
return f"data get entity {name} SelectedItem"
|
|
return _entitydata_query(player)
|
|
|
|
|
|
def _head_query(player: str, version: Version) -> str:
|
|
name = sanitize_name(player)
|
|
if is_at_least(version, "1.13"):
|
|
return f"data get entity {name} equipment.head"
|
|
return _entitydata_query(player)
|
|
|
|
|
|
def _head_slot(version: Version) -> str:
|
|
if is_at_least(version, "1.13"):
|
|
return "armor.head"
|
|
return "slot.armor.head"
|
|
|
|
|
|
def _hand_slot(version: Version, hotbar_index: int = 0) -> str:
|
|
if is_at_least(version, "1.13"):
|
|
return "weapon.mainhand"
|
|
if is_at_least(version, "1.9"):
|
|
return "slot.weapon"
|
|
return f"slot.hotbar.{hotbar_index}"
|
|
|
|
|
|
def _extract_selected_slot(payload: str) -> int:
|
|
body = strip_data_prefix(payload)
|
|
m = re.search(r"SelectedItemSlot\s*:\s*(\d+)", body)
|
|
return int(m.group(1)) if m else 0
|
|
|
|
|
|
def _extract_head_item(payload: str, version: Version) -> Optional[str]:
|
|
body = strip_data_prefix(payload)
|
|
if not body or not looks_like_item_snbt(body):
|
|
return None
|
|
if is_at_least(version, "1.13"):
|
|
body = body.strip()
|
|
if _is_air(body):
|
|
return None
|
|
return body
|
|
m = re.search(r"Inventory\s*:", body)
|
|
if m is None:
|
|
return None
|
|
i = m.end()
|
|
while i < len(body) and body[i].isspace():
|
|
i += 1
|
|
if i >= len(body) or body[i] != "[":
|
|
return None
|
|
depth = 0
|
|
inv = ""
|
|
for j in range(i, len(body)):
|
|
if body[j] == "[":
|
|
depth += 1
|
|
elif body[j] == "]":
|
|
depth -= 1
|
|
if depth == 0:
|
|
inv = body[i : j + 1]
|
|
break
|
|
if not inv:
|
|
return None
|
|
for sm in re.finditer(r"\{(?:[^{}]|\{[^{}]*\})*\}", inv):
|
|
if re.search(r"Slot\s*:\s*103b?", sm.group(0)):
|
|
item = sm.group(0)
|
|
return None if _is_air(item) else item
|
|
return None
|
|
|
|
|
|
def _split_snbt_compound(snbt: str, key: str) -> Optional[str]:
|
|
m = re.search(rf"(?:^|[{{,\s]){re.escape(key)}\s*:\s*(\{{)", snbt)
|
|
if m is None:
|
|
return None
|
|
start = m.start(1)
|
|
depth = 0
|
|
for i in range(start, len(snbt)):
|
|
if snbt[i] == "{":
|
|
depth += 1
|
|
elif snbt[i] == "}":
|
|
depth -= 1
|
|
if depth == 0:
|
|
return snbt[start : i + 1]
|
|
return None
|
|
|
|
|
|
def _components_to_command_inner(components_compound: str) -> str:
|
|
"""
|
|
Convert a data-get components compound into item-command component list form.
|
|
|
|
data get (1.20.5+): components:{"minecraft:custom_name":{...}, "minecraft:damage":1}
|
|
item replace form: minecraft:item[minecraft:custom_name={...},minecraft:damage=1]
|
|
"""
|
|
inner = components_compound.strip()[1:-1].strip()
|
|
if not inner:
|
|
return ""
|
|
# Top-level key:value pairs. Values may be compounds/lists/scalars.
|
|
parts = []
|
|
i = 0
|
|
n = len(inner)
|
|
while i < n:
|
|
while i < n and inner[i] in ", \t\n":
|
|
i += 1
|
|
if i >= n:
|
|
break
|
|
# key
|
|
key_m = re.match(r'"([^"]+)"', inner[i:])
|
|
if key_m is None:
|
|
key_m = re.match(r"([A-Za-z0-9_.:\-]+)", inner[i:])
|
|
if key_m is None:
|
|
i += 1
|
|
continue
|
|
key = key_m.group(1)
|
|
i += key_m.end()
|
|
while i < n and inner[i].isspace():
|
|
i += 1
|
|
if i >= n or inner[i] != ":":
|
|
continue
|
|
i += 1
|
|
while i < n and inner[i].isspace():
|
|
i += 1
|
|
# value
|
|
if i >= n:
|
|
break
|
|
if inner[i] == "{":
|
|
depth = 0
|
|
start = i
|
|
while i < n:
|
|
if inner[i] == "{":
|
|
depth += 1
|
|
elif inner[i] == "}":
|
|
depth -= 1
|
|
if depth == 0:
|
|
i += 1
|
|
break
|
|
i += 1
|
|
value = inner[start:i]
|
|
elif inner[i] == "[":
|
|
depth = 0
|
|
start = i
|
|
while i < n:
|
|
if inner[i] == "[":
|
|
depth += 1
|
|
elif inner[i] == "]":
|
|
depth -= 1
|
|
if depth == 0:
|
|
i += 1
|
|
break
|
|
i += 1
|
|
value = inner[start:i]
|
|
elif inner[i] == '"':
|
|
start = i
|
|
i += 1
|
|
while i < n:
|
|
if inner[i] == "\\":
|
|
i += 2
|
|
continue
|
|
if inner[i] == '"':
|
|
i += 1
|
|
break
|
|
i += 1
|
|
value = inner[start:i]
|
|
else:
|
|
start = i
|
|
while i < n and inner[i] not in ",":
|
|
i += 1
|
|
value = inner[start:i].strip()
|
|
parts.append(f"{key}={value}")
|
|
return ", ".join(parts)
|
|
|
|
|
|
def _item_to_command_form(snbt: str, version: Version) -> Tuple[str, Optional[int]]:
|
|
id_m = re.search(r'id\s*:\s*"([^"]+)"', snbt)
|
|
if id_m is None:
|
|
return "minecraft:air", 1
|
|
item_id = id_m.group(1)
|
|
cnt: Optional[int] = None
|
|
if is_at_least(version, "1.20.5"):
|
|
c_m = re.search(r"\bcount\s*:\s*(\d+)", snbt)
|
|
cnt = int(c_m.group(1)) if c_m else 1
|
|
comps = _split_snbt_compound(snbt, "components")
|
|
if comps and comps != "{}":
|
|
inner = _components_to_command_inner(comps)
|
|
if inner:
|
|
return f"{item_id}[{inner}]", cnt
|
|
return item_id, cnt
|
|
|
|
c_m = re.search(r"Count\s*:\s*(\d+)", snbt)
|
|
cnt = int(c_m.group(1)) if c_m else 1
|
|
tag = _split_snbt_compound(snbt, "tag")
|
|
if tag and tag != "{}":
|
|
return f"{item_id}{tag}", cnt
|
|
return item_id, cnt
|
|
|
|
|
|
def _replace_with(player: str, slot: str, item_snbt: str, version: Version) -> str:
|
|
name = sanitize_name(player)
|
|
part, count = _item_to_command_form(item_snbt, version)
|
|
if part.endswith(":air") or part == "air":
|
|
part = "air"
|
|
count = None
|
|
if is_at_least(version, "1.17"):
|
|
cmd = f"item replace entity {name} {slot} with {part}"
|
|
if count is not None and count != 1:
|
|
cmd += f" {count}"
|
|
return cmd
|
|
cmd = f"replaceitem entity {name} {slot} {part}"
|
|
if count is not None and count != 1:
|
|
cmd += f" {count}"
|
|
return cmd
|
|
|
|
|
|
def _extract_item_payload(payload: str, version: Version) -> Optional[str]:
|
|
body = strip_data_prefix(payload)
|
|
if not looks_like_item_snbt(body):
|
|
return None
|
|
if is_at_least(version, "1.13"):
|
|
return body.strip()
|
|
return parse_selected_item(body)
|
|
|
|
|
|
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:
|
|
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)
|
|
|
|
if is_at_least(version, "1.17"):
|
|
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")
|
|
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))
|
|
|
|
src.reply(RText(tr("hat_success"), RColor.green))
|
|
return True
|
|
|
|
|
|
def do_hat(server: PluginServerInterface, src: CommandSource) -> None:
|
|
player = require_player(src)
|
|
if player is None:
|
|
return
|
|
if not check_permission(src, config.permission):
|
|
return
|
|
version = require_version(server, src)
|
|
if version is None:
|
|
return
|
|
if not _can_swap(version):
|
|
src.reply(RText(tr("hat_unsupported_version", str(version)), RColor.red))
|
|
return
|
|
if not server.is_rcon_running():
|
|
src.reply(RText(tr("hat_need_rcon"), RColor.red))
|
|
return
|
|
if not check_cooldown(src, player, "hat", config.hat_cooldown_seconds):
|
|
return
|
|
|
|
if _do_swap(server, src, player, version):
|
|
mark_cooldown(player, "hat")
|
|
|
|
|
|
def register(server: PluginServerInterface) -> None:
|
|
server.register_command(Literal("!!hat").runs(lambda src: do_hat(server, src)))
|
|
server.register_help_message("!!hat", tr("hat_help"))
|