From d5ba9743edb8c40966257154afca66356d3c6028 Mon Sep 17 00:00:00 2001 From: l3126596029 Date: Sun, 13 Sep 2026 02:32:54 +0800 Subject: [PATCH] release: Iridium 26.9.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MCDR plugin: item share, hat, player head, calculator, join MOTD. All rights reserved. Author: 云镜之端/SKYMirror --- .gitignore | 8 + CHANGELOG.md | 20 +++ LICENSE | 14 ++ README.md | 169 ++++++++++++++++++++ iridium/__init__.py | 39 +++++ iridium/calculator.py | 112 ++++++++++++++ iridium/config.py | 56 +++++++ iridium/hat.py | 336 ++++++++++++++++++++++++++++++++++++++++ iridium/head.py | 52 +++++++ iridium/join_tip.py | 242 +++++++++++++++++++++++++++++ iridium/share.py | 213 +++++++++++++++++++++++++ iridium/utils.py | 142 +++++++++++++++++ iridium/version_util.py | 50 ++++++ lang/en_us.yml | 32 ++++ lang/zh_cn.yml | 32 ++++ mcdreforged.plugin.json | 18 +++ pack.sh | 8 + tests/test_pure.py | 253 ++++++++++++++++++++++++++++++ 18 files changed, 1796 insertions(+) create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 iridium/__init__.py create mode 100644 iridium/calculator.py create mode 100644 iridium/config.py create mode 100644 iridium/hat.py create mode 100644 iridium/head.py create mode 100644 iridium/join_tip.py create mode 100644 iridium/share.py create mode 100644 iridium/utils.py create mode 100644 iridium/version_util.py create mode 100644 lang/en_us.yml create mode 100644 lang/zh_cn.yml create mode 100644 mcdreforged.plugin.json create mode 100755 pack.sh create mode 100644 tests/test_pure.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e45151d --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +__pycache__/ +*.py[cod] +*.egg-info/ +.pytest_cache/ +config/ +logs/ +.DS_Store +Iridium.mcdr diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..7f1a764 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,20 @@ +# Changelog + +本文件记录 Iridium 的重要变更。 + +## [26.9.1] - 2026-09-13 + +### Added +- `!!share`:展示主手物品(悬停完整数据,1.16+ 点击复制,可选提示音) +- `!!hat`:主手物品与头部装备互换(需 RCON) +- `!!head [player]`:获取一个玩家头颅,支持冷却与权限 +- `!!c `:游戏内四则运算计算器(AST 白名单,零依赖) +- 进服 MOTD:开服天数、自定义文案、可点击链接、`§` 颜色 +- 进服命令提示(可点击填入聊天框) +- 按 Minecraft 版本分支;不支持的功能会明确提示 + +### Security +- 玩家名统一清洗为 `[A-Za-z0-9_]` 并限长后再拼进 `/give`、`data get`、`item replace` +- 控制台命令去除换行,防止拆成多行二次执行 +- tellraw 对物品 SNBT 做 JSON 转义与长度截断 +- MOTD 链接仅允许 `http://` / `https://` diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..64768bb --- /dev/null +++ b/LICENSE @@ -0,0 +1,14 @@ +Copyright (c) 2026 云镜之端/SKYMirror. All Rights Reserved. + +本软件及相关文档文件(以下统称“本软件”)之著作权归 云镜之端/SKYMirror 所有。 + +保留所有权益。未经著作权人书面许可,不得对本软件进行复制、修改、合并、发布、 +分发、再许可、出售或以其他方式使用(包括但不限于商业用途)。 + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..0957789 --- /dev/null +++ b/README.md @@ -0,0 +1,169 @@ +# Iridium + +MCDReforged 插件:物品展示 / 戴头 / 获取头颅 / 游戏内计算器 / 进服 MOTD。 + +| | | +|---|---| +| 版本 | **26.9.1** | +| 作者 | 云镜之端/SKYMirror | +| 许可 | 保留所有权益(All Rights Reserved) | +| 前置 | MCDReforged `>= 2.6.0` | +| MC | **1.7.10+**(按版本分支;不支持的功能会提示) | +| 依赖 | 无第三方 Python 包 | + +## 功能一览 + +| 功能 | 说明 | +|------|------| +| `!!share` | 展示主手物品:悬停看完整 NBT/组件,1.16+ 可点击复制,可选提示音 | +| `!!hat` | 主手与头部装备互换 | +| `!!head [player]` | 给自己或指定玩家 **1 个** 头颅;有冷却 | +| `!!c <表达式>` | 四则运算 + 括号:`+ - * / // % ** ^` | +| 进服 MOTD | 开服天数、自定义行、可点击链接、`§` 颜色 | +| 进服命令提示 | 可点击填入聊天框 | + +## 安装 + +### 方式 A:使用发布包 + +1. 下载 `Iridium.mcdr` +2. 放入 MCDR 的 `plugins/` 目录 +3. `!!MCDR reload plugin` 或重启 MCDR + +### 方式 B:从源码打包 + +```bash +./pack.sh +# 或 +zip -r Iridium.mcdr iridium lang mcdreforged.plugin.json LICENSE +``` + +### 使用 `!!hat` / 无 RCON 的 `!!share` + +在 `server.properties` 中启用 RCON,并在 MCDR 配置里填好连接信息: + +```properties +enable-rcon=true +rcon.port=25575 +rcon.password=your_password +``` + +| 功能 | 无 RCON | 有 RCON | +|------|---------|---------| +| `!!share` | 可用(解析控制台回包,空手可能超时提示) | 推荐 | +| `!!hat` | 不可用(会提示) | 必需 | + +## 命令 + +| 命令 | 权限(默认) | 说明 | +|------|--------------|------| +| `!!share` | `permission` | 展示主手物品 | +| `!!hat` | `permission` | 手与头盔交换 | +| `!!head` | `permission` | 给自己一个头颅 | +| `!!head ` | `permission` | 给自己一个该玩家的头颅 | +| `!!c <表达式>` | `permission` | 计算并私聊回复结果 | + +帮助列表可用 `!!help` 查看(已注册 help message)。 + +## 版本能力 + +| 功能 | 1.7.10 | 1.8–1.12 | 1.13–1.16 | 1.17–1.20.4 | 1.20.5+ | +|------|:------:|:--------:|:---------:|:-----------:|:-------:| +| `!!c` | ✅ | ✅ | ✅ | ✅ | ✅ | +| `!!head` | ✅ | ✅ | ✅ | ✅ | ✅ profile 组件 | +| `!!share` | ❌ 提示 | ✅ entitydata | ✅ data get | ✅ | ✅ | +| `!!hat` | ❌ 提示 | ✅ RCON | ✅ RCON | ✅ RCON | ✅ RCON | +| MOTD / 进服提示 | ✅ | ✅ | ✅ | ✅ | ✅ | + +无法检测版本时会提示;可在配置中设置 `force_version`(如 `"1.12.2"`)。 + +## 配置 + +路径:`config/iridium.json`(首次加载自动生成) + +```json +{ + "permission": 1, + "head_cooldown_seconds": 60, + "hat_cooldown_seconds": 3, + "share_sound": true, + "force_version": "", + "join_tip": true, + "join_tip_delay_seconds": 1.5, + "motd_enabled": true, + "motd_start_day": "", + "motd_lines": [ + "§6欢迎 §e{player}§6 加入服务器!", + "§7服务器已开服 §b{days}§7 天", + "§7官网: {link:§b点击打开官网|https://www.skymirror.top}", + "§7QQ群: §f985402607" + ] +} +``` + +| 字段 | 默认 | 说明 | +|------|------|------| +| `permission` | `1` | 各命令最低权限(0 控制台 / 1 普通玩家 / 2 OP) | +| `head_cooldown_seconds` | `60` | `!!head` 冷却;`0` 不限制 | +| `hat_cooldown_seconds` | `3` | `!!hat` 冷却;`0` 不限制 | +| `share_sound` | `true` | `!!share` 是否播放提示音 | +| `force_version` | `""` | 强制 MC 版本;留空自动检测 | +| `join_tip` | `true` | 进服是否提示可用命令 | +| `join_tip_delay_seconds` | `1.5` | 进服消息延迟;`0` 立即 | +| `motd_enabled` | `true` | 是否发送 MOTD | +| `motd_start_day` | `""` | 开服日期 `YYYY-MM-DD`(当天算第 1 天);留空不显示天数 | +| `motd_lines` | 见上 | MOTD 文本行 | + +修改配置后执行 `!!MCDR reload plugin` 生效。 + +### MOTD 语法 + +| 语法 | 含义 | +|------|------| +| `{player}` | 进服玩家名(已清洗) | +| `{days}` / `{day}` | 开服天数 | +| `{online}` | 在线人数,拿不到为 `?` | +| `{link:文字\|https://url}` | 可点击链接 | +| `{link:https://url}` | 可点击链接,显示完整 URL | +| `§` | 原版颜色/样式(§a §e §l §r 等) | + +仅当 `motd_lines` 中**没有** `{days}`/`{day}` 时,才会在末尾自动追加「开服第 N 天」。 + +## 常见问题 + +**`!!hat` 说需要 RCON?** +未开启 RCON,或 MCDR 未配置 RCON 连接。见上文「使用 !!hat」。 + +**进服没有 MOTD?** +检查 `motd_enabled`、`join_tip_delay_seconds`;若延迟 >0,消息会在进服后约 1.5 秒出现。 + +**版本检测失败?** +等待 VersionDetector 完成,或配置 `force_version`。 + +**`!!share` 超时?** +多为无 RCON 且空手(控制台无 `SelectedItem` 回包)。可开 RCON 或先手持物品再试。 + +**如何关闭进服提示?** +`join_tip` 与 `motd_enabled` 可分别设为 `false`。 + +## 安全说明 + +- 计算器:字符 + AST 白名单,不执行任意 Python;指数上限 64。 +- 玩家名进入游戏命令前统一清洗为 `[A-Za-z0-9_]`(最长 32)。 +- 控制台命令会去掉换行,避免拆成多行执行。 +- MOTD 链接仅 `http(s)`。 +- `!!head` 每次固定 1 个,并受冷却限制。 + +## 开发与测试 + +```bash +python3 tests/test_pure.py +``` + +无需启动 Minecraft;覆盖命令拼装、NBT 解析、计算器、MOTD 与清洗逻辑。 + +## 版权 + +Copyright (c) 2026 云镜之端/SKYMirror. All Rights Reserved. + +未经书面许可,不得复制、修改、分发或用于商业用途。详见 [LICENSE](LICENSE)。 \ No newline at end of file diff --git a/iridium/__init__.py b/iridium/__init__.py new file mode 100644 index 0000000..6bfe837 --- /dev/null +++ b/iridium/__init__.py @@ -0,0 +1,39 @@ +"""Iridium — item share / hat / head / calculator for MCDReforged. + +Copyright (c) 2026 云镜之端/SKYMirror. All Rights Reserved. +""" + +from mcdreforged.api.all import * + +import iridium.calculator as calculator +import iridium.hat as hat +import iridium.head as head +import iridium.join_tip as join_tip +import iridium.share as share +from iridium.config import CONFIG_FILE, Config, apply_config +from iridium.version_util import tr + + +def on_load(server: PluginServerInterface, prev_module) -> None: + loaded = server.load_config_simple( + CONFIG_FILE, + target_class=Config, + in_data_folder=False, + ) + apply_config(loaded) + server.logger.info(tr("loaded")) + + share.register(server) + hat.register(server) + head.register(server) + calculator.register(server) + join_tip.register(server) + + server.register_event_listener(MCDRPluginEvents.GENERAL_INFO, share.handle_info) + + ver_hint = loaded.force_version or "auto" + server.logger.info(tr("version_mode", ver_hint)) + + +def on_unload(server: PluginServerInterface) -> None: + server.logger.info(tr("unloaded")) diff --git a/iridium/calculator.py b/iridium/calculator.py new file mode 100644 index 0000000..5fd1e94 --- /dev/null +++ b/iridium/calculator.py @@ -0,0 +1,112 @@ +import ast +import operator +import re +from typing import Any, Union + +from mcdreforged.api.all import * + +from iridium.config import config +from iridium.utils import check_permission +from iridium.version_util import tr + +Number = Union[int, float] + +_BIN_OPS = { + ast.Add: operator.add, + ast.Sub: operator.sub, + ast.Mult: operator.mul, + ast.Div: operator.truediv, + ast.FloorDiv: operator.floordiv, + ast.Mod: operator.mod, + ast.Pow: operator.pow, +} +_UNARY_OPS = { + ast.UAdd: operator.pos, + ast.USub: operator.neg, +} + +MAX_EXPR_LEN = 200 +MAX_POW = 64 + + +class CalcError(Exception): + pass + + +def _eval_node(node: ast.AST) -> Any: + if isinstance(node, ast.Expression): + return _eval_node(node.body) + # ast.Constant covers int/float on Python 3.8+; do not touch ast.Num (removed in 3.12) + if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)): + return node.value + if isinstance(node, ast.UnaryOp) and type(node.op) in _UNARY_OPS: + return _UNARY_OPS[type(node.op)](_eval_node(node.operand)) + if isinstance(node, ast.BinOp) and type(node.op) in _BIN_OPS: + left = _eval_node(node.left) + right = _eval_node(node.right) + if isinstance(node.op, ast.Pow): + if isinstance(right, (int, float)) and abs(right) > MAX_POW: + raise CalcError("power too large") + return _BIN_OPS[type(node.op)](left, right) + raise CalcError("unsupported expression") + + +def safe_eval(expression: str) -> Number: + expr = expression.strip() + if not expr: + raise CalcError("empty") + if len(expr) > MAX_EXPR_LEN: + raise CalcError("expression too long") + if not re.fullmatch(r"[0-9+\-*/%().\s^]+", expr): + raise CalcError("illegal character") + expr = expr.replace("^", "**") + try: + tree = ast.parse(expr, mode="eval") + except SyntaxError as e: + raise CalcError(str(e)) + return _eval_node(tree) + + +def _format_result(value: Number) -> str: + if isinstance(value, float): + if value.is_integer() and abs(value) < 1e15: + return str(int(value)) + return f"{value:.12g}" + return str(value) + + +def do_calc(server: PluginServerInterface, src: CommandSource, context: dict) -> None: + if not check_permission(src, config.permission): + return + expression = context.get("expression", "").strip() + try: + result = safe_eval(expression) + except CalcError as e: + src.reply(RText(tr("calc_error", expression, str(e)), RColor.red)) + return + except ZeroDivisionError: + src.reply(RText(tr("calc_div_zero"), RColor.red)) + return + except Exception as e: + src.reply(RText(tr("calc_error", expression, str(e)), RColor.red)) + return + + out = _format_result(result) + # Private reply to avoid flooding public chat + src.reply( + RTextList( + RText("[Iridium] ", RColor.gray), + RText(expression, RColor.yellow), + RText(" = ", RColor.gray), + RText(out, RColor.aqua, styles=RStyle.bold), + ) + ) + + +def register(server: PluginServerInterface) -> None: + builder = SimpleCommandBuilder() + builder.command("!!c", lambda src: src.reply(RText(tr("calc_usage"), RColor.yellow))) + builder.command("!!c ", do_calc) + builder.arg("expression", GreedyText) + builder.register(server) + server.register_help_message("!!c ", tr("calc_help")) diff --git a/iridium/config.py b/iridium/config.py new file mode 100644 index 0000000..3768c44 --- /dev/null +++ b/iridium/config.py @@ -0,0 +1,56 @@ +from typing import List + +from mcdreforged.api.all import * + + +class Config(Serializable): + # 使用 !!share / !!hat / !!head / !!c 所需的最低权限等级 + permission: int = 1 + # !!head 冷却时间(秒),0 表示不限制 + head_cooldown_seconds: int = 60 + # !!hat 冷却时间(秒),0 表示不限制 + hat_cooldown_seconds: int = 3 + # !!share 是否播放提示音 + share_sound: bool = True + # 强制指定 MC 版本号(如 "1.12.2"),留空则自动检测 + force_version: str = "" + # 玩家加入时是否提示可用命令 + join_tip: bool = True + # 加入提示延迟(秒),避免和进服消息挤在一起 + join_tip_delay_seconds: float = 1.5 + # 是否发送进服 MOTD(开服天数 + 自定义行) + motd_enabled: bool = True + # 开服日期,格式 YYYY-MM-DD,用于计算开服天数;留空则不显示天数 + motd_start_day: str = "" + # MOTD 文本行。支持: + # {player} 玩家名 + # {days} 开服天数 + # {online} 在线人数(拿不到时显示 ?) + # {link:显示文字|https://url} 可点击链接 + # {link:https://url} 链接,显示为完整 URL + # § 颜色代码 + motd_lines: List[str] = [ + "§6欢迎 §e{player}§6 加入服务器!", + "§7服务器已开服 §b{days}§7 天", + "§7官网: {link:§b点击打开官网|https://www.skymirror.top}", + "§7QQ群: §f985402607", + ] + + +CONFIG_FILE = "config/iridium.json" + +# 模块级单例;on_load 时原地更新字段,避免各模块持有过期引用 +config = Config() + + +def apply_config(new_cfg: Config) -> None: + config.permission = new_cfg.permission + config.head_cooldown_seconds = new_cfg.head_cooldown_seconds + config.hat_cooldown_seconds = new_cfg.hat_cooldown_seconds + config.share_sound = new_cfg.share_sound + config.force_version = new_cfg.force_version + config.join_tip = new_cfg.join_tip + config.join_tip_delay_seconds = new_cfg.join_tip_delay_seconds + config.motd_enabled = new_cfg.motd_enabled + config.motd_start_day = new_cfg.motd_start_day + config.motd_lines = list(new_cfg.motd_lines) diff --git a/iridium/hat.py b/iridium/hat.py new file mode 100644 index 0000000..312d366 --- /dev/null +++ b/iridium/hat.py @@ -0,0 +1,336 @@ +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")) diff --git a/iridium/head.py b/iridium/head.py new file mode 100644 index 0000000..b4969ef --- /dev/null +++ b/iridium/head.py @@ -0,0 +1,52 @@ +from mcdreforged.api.all import * + +from iridium.config import config +from iridium.utils import ( + check_cooldown, + check_permission, + mark_cooldown, + require_player, + safe_execute, + sanitize_name, +) +from iridium.version_util import is_at_least, require_version, tr + + +def _give_head_command(player: str, target: str, version: Version) -> str: + # Defense in depth: never interpolate an unsanitized name + safe_player = sanitize_name(player) or "Steve" + safe_target = sanitize_name(target) or safe_player + if is_at_least(version, "1.20.5"): + return f'give {safe_player} minecraft:player_head[minecraft:profile="{safe_target}"] 1' + if is_at_least(version, "1.13"): + return f'give {safe_player} minecraft:player_head{{SkullOwner:"{safe_target}"}} 1' + return f'give {safe_player} minecraft:skull 1 3 {{SkullOwner:"{safe_target}"}}' + + +def do_head(server: PluginServerInterface, src: CommandSource, context: dict) -> 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 + + target = context.get("player_name") or player + safe_target = sanitize_name(target) or sanitize_name(player) or player + if not check_cooldown(src, player, "head", config.head_cooldown_seconds): + return + + safe_execute(server, _give_head_command(player, safe_target, version)) + mark_cooldown(player, "head") + src.reply(RText(tr("head_success", safe_target), RColor.green)) + + +def register(server: PluginServerInterface) -> None: + builder = SimpleCommandBuilder() + builder.command("!!head", lambda src: do_head(server, src, {})) + builder.command("!!head ", lambda src, ctx: do_head(server, src, ctx)) + builder.arg("player_name", Text) + builder.register(server) + server.register_help_message("!!head [player]", tr("head_help", config.head_cooldown_seconds)) diff --git a/iridium/join_tip.py b/iridium/join_tip.py new file mode 100644 index 0000000..5074464 --- /dev/null +++ b/iridium/join_tip.py @@ -0,0 +1,242 @@ +import re +import threading +from datetime import datetime +from typing import List, Optional, Tuple + +from mcdreforged.api.all import * + +from iridium.config import config +from iridium.utils import sanitize_name +from iridium.version_util import tr + +LINK_WITH_TEXT_RE = re.compile(r"\{link:(?P[^|}]+)\|(?P[^}]+)\}") +LINK_URL_ONLY_RE = re.compile(r"\{link:(?P[^}|]+)\}") + +_COLOR_MAP = { + "0": RColor.black, + "1": RColor.dark_blue, + "2": RColor.dark_green, + "3": RColor.dark_aqua, + "4": RColor.dark_red, + "5": RColor.dark_purple, + "6": RColor.gold, + "7": RColor.gray, + "8": RColor.dark_gray, + "9": RColor.blue, + "a": RColor.green, + "b": RColor.aqua, + "c": RColor.red, + "d": RColor.light_purple, + "e": RColor.yellow, + "f": RColor.white, +} + +_STYLE_MAP = { + "k": RStyle.obfuscated, + "l": RStyle.bold, + "m": RStyle.strikethrough, + "n": RStyle.underlined, + "o": RStyle.italic, +} + + +def server_days() -> Optional[int]: + raw = (config.motd_start_day or "").strip() + if not raw: + return None + for fmt in ("%Y-%m-%d", "%Y/%m/%d", "%Y.%m.%d"): + try: + start = datetime.strptime(raw, fmt) + except ValueError: + continue + return max(0, (datetime.now() - start).days) + 1 + return None + + +def online_hint(server: ServerInterface) -> str: + try: + info = server.get_server_information() + if info is not None and getattr(info, "player_count", None) is not None: + return str(info.player_count) + except Exception: + pass + return "?" + + +def replace_placeholders(line: str, player: str, server: ServerInterface) -> str: + days = server_days() + days_text = str(days) if days is not None else "?" + # Sanitize player name before it can re-enter the {link:...} parser or § codes + safe_player = sanitize_name(player) or "Player" + return ( + line.replace("{player}", safe_player) + .replace("{days}", days_text) + .replace("{day}", days_text) + .replace("{online}", online_hint(server)) + ) + + +def parse_colored_pieces(segment: str) -> List[RText]: + """Split a string with § color codes into RText pieces.""" + pieces: List[RText] = [] + color: Optional[RColor] = None + styles: List[RStyle] = [] + buf = "" + + def flush() -> None: + nonlocal buf + if not buf: + return + pieces.append(RText(buf, color=color, styles=list(styles) if styles else None)) + buf = "" + + i = 0 + n = len(segment) + while i < n: + if segment[i] == "§" and i + 1 < n: + code = segment[i + 1].lower() + i += 2 + if code == "r": + flush() + color = None + styles = [] + continue + if code in _COLOR_MAP: + flush() + color = _COLOR_MAP[code] + styles = [] + continue + if code in _STYLE_MAP: + flush() + style = _STYLE_MAP[code] + if style not in styles: + styles.append(style) + continue + buf += f"§{code}" + continue + buf += segment[i] + i += 1 + flush() + return pieces or [RText(segment or "")] + + +def _apply_click(piece: RText, url: str) -> RText: + return piece.c(RAction.open_url, url).h(RText(url, RColor.gray)) + + +def render_motd_line(line: str, player: str, server: ServerInterface) -> List[RText]: + raw = replace_placeholders(line, player, server) + + # Tokenize: plain text | (text, url) + tokens: List[Tuple[str, str, Optional[str]]] = [] + last = 0 + + def push_text(chunk: str) -> None: + if chunk: + tokens.append(("text", chunk, None)) + + for match in LINK_WITH_TEXT_RE.finditer(raw): + push_text(raw[last : match.start()]) + tokens.append(("link", match.group("text"), match.group("url").strip())) + last = match.end() + + # Remaining part may still contain {link:url} + rest = raw[last:] + pos = 0 + for match in LINK_URL_ONLY_RE.finditer(rest): + push_text(rest[pos : match.start()]) + url = match.group("url").strip() + tokens.append(("link", url, url)) + pos = match.end() + push_text(rest[pos:]) + if not tokens: + tokens.append(("text", raw, None)) + + out: List[RText] = [] + for kind, text, url in tokens: + if kind == "text": + out.extend(parse_colored_pieces(text)) + continue + assert url is not None + if not url.lower().startswith(("http://", "https://")): + out.extend(parse_colored_pieces(text)) + continue + colored = parse_colored_pieces(text) + if not colored: + colored = [RText(text, RColor.aqua)] + for piece in colored: + out.append(_apply_click(piece, url)) + return out + + +def build_motd(player: str, server: ServerInterface) -> Optional[RTextList]: + if not config.motd_enabled: + return None + lines = list(config.motd_lines or []) + if not lines: + return None + parts: List[RText] = [ + RText(tr("motd_header"), RColor.gold, styles=[RStyle.bold]) + ] + for line in lines: + parts.append(RText("\n")) + parts.extend(render_motd_line(line, player, server)) + # Only auto-append the day footer when no line already uses {days}/{day} + days = server_days() + if days is not None and not any(("{days}" in ln or "{day}" in ln) for ln in lines): + parts.append(RText("\n")) + parts.append(RText(tr("motd_days", days), RColor.gray)) + return RTextList(*parts) + + +def command_line(cmd: str, desc: str) -> List[RText]: + return [ + RText(cmd, RColor.aqua, styles=[RStyle.underlined]) + .c(RAction.suggest_command, cmd) + .h(RText(tr("join_tip_click"), RColor.gray)), + RText(" - ", RColor.dark_gray), + RText(desc, RColor.white), + ] + + +def build_command_tip() -> RTextList: + parts: List[RText] = [ + RText("[Iridium] ", RColor.gray), + RText(tr("join_tip_header"), RColor.gold), + ] + for cmd, desc in ( + ("!!share", tr("share_help")), + ("!!hat", tr("hat_help")), + ("!!head", tr("head_help_short")), + ("!!c 1+1", tr("calc_help")), + ): + parts.append(RText("\n")) + parts.extend(command_line(cmd, desc)) + return RTextList(*parts) + + +def send_join_message(server: ServerInterface, player: str) -> None: + try: + motd = build_motd(player, server) + if motd is not None: + server.tell(player, motd) + if config.join_tip: + server.tell(player, build_command_tip()) + except Exception as e: + server.logger.debug(f"join message failed for {player}: {e}") + + +def on_player_joined(server: PluginServerInterface, player: str, info: Info) -> None: + if not config.motd_enabled and not config.join_tip: + return + delay = max(0.0, float(config.join_tip_delay_seconds)) + if delay <= 0: + send_join_message(server, player) + return + timer = threading.Timer(delay, lambda: send_join_message(server, player)) + timer.daemon = True + timer.start() + + +def register(server: PluginServerInterface) -> None: + server.register_event_listener(MCDRPluginEvents.PLAYER_JOIN, on_player_joined) diff --git a/iridium/share.py b/iridium/share.py new file mode 100644 index 0000000..7aaa78c --- /dev/null +++ b/iridium/share.py @@ -0,0 +1,213 @@ +import json +import re +import threading +import time +from typing import Dict, Optional, Tuple + +from mcdreforged.api.all import * + +from iridium.config import config +from iridium.utils import ( + check_permission, + looks_like_item_snbt, + 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 + +# pending share requests: player -> (src, version, expire_ts) +_pending: Dict[str, Tuple[CommandSource, Version, float]] = {} +_pending_lock = threading.Lock() + +PENDING_TTL = 5.0 +# Keep tellraw well under typical command-length limits +MAX_SNBT_LEN = 20000 + +PLAYER_DATA_RE = re.compile( + r"^(?P\S+) has the following entity data: (?P.*)$", + re.IGNORECASE, +) + + +def _can_read_selected_item(version: Version) -> bool: + return is_at_least(version, "1.8") + + +def _query_command(player: str, version: Version) -> str: + name = sanitize_name(player) + if is_at_least(version, "1.13"): + return f"data get entity {name} SelectedItem" + return f"entitydata {name} {{}}" + + +def _sound_command(version: Version) -> str: + if is_at_least(version, "1.9"): + return "playsound minecraft:entity.arrow.hit_player master @a" + return "playsound random.successful_hit @a" + + +def _is_air(snbt: Optional[str]) -> bool: + if snbt is None: + return True + stripped = snbt.strip() + return stripped in ("{}", "", "[]") or bool( + re.search(r'id\s*:\s*"(minecraft:)?air"', stripped) + ) + + +def _json_str(value: str) -> str: + return json.dumps(value, ensure_ascii=False)[1:-1] + + +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: + item_snbt = item_snbt[:MAX_SNBT_LEN] + "..." + snbt = _json_str(item_snbt) + player_js = _json_str(sanitize_name(player) or player) + + if is_at_least(version, "1.21.5"): + hover = '{"action":"show_item","contents":"' + snbt + '"}' + else: + hover = '{"action":"show_item","value":"' + snbt + '"}' + + if is_at_least(version, "1.16"): + click = '{"action":"copy_to_clipboard","value":"' + snbt + '"}' + click_label = _json_str(tr("click_copy")) + else: + # pre-1.16 has no copy_to_clipboard; suggest the version-appropriate query cmd + safe_player = sanitize_name(player) + if is_at_least(version, "1.13"): + suggest = f"/data get entity {safe_player} SelectedItem" + else: + suggest = f"/entitydata {safe_player} {{}}" + 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_js = _json_str(display) + + json_text = ( + '[{"text":"[Iridium] ","color":"gray"},' + '{"text":"' + player_js + '","color":"yellow"},' + '{"text":" ' + _json_str(tr("share_showing")) + ' "},' + '{"text":"' + display_js + '","color":"aqua","hoverEvent":' + hover + "}," + '{"text":" [' + click_label + ']","color":"aqua","bold":true,' + '"underlined":true,"hoverEvent":' + hover + ',"clickEvent":' + click + "}]" + ) + return f"tellraw @a {json_text}" + + +def _send_share( + server: PluginServerInterface, + src: CommandSource, + player: str, + item_snbt: str, + version: Version, +) -> bool: + if _is_air(item_snbt) or not looks_like_item_snbt(item_snbt): + src.reply(RText(tr("hand_is_empty"), RColor.red)) + return False + safe_execute(server, _build_tellraw(player, item_snbt, version)) + if config.share_sound: + safe_execute(server, _sound_command(version)) + src.reply(RText(tr("share_done"), RColor.green)) + return True + + +def _extract_item(payload: str, version: Version) -> Optional[str]: + """Normalize an entity-data payload into a single-item SNBT string, or None.""" + if not payload: + return None + 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 _purge_expired_pending() -> None: + now = time.time() + expired = [p for p, (_, _, exp) in _pending.items() if now >= exp] + for p in expired: + _pending.pop(p, None) + + +def do_share(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_read_selected_item(version): + src.reply(RText(tr("share_unsupported_version", str(version)), RColor.red)) + return + + query = _query_command(player, version) + raw = rcon_query(server, query) + if raw is not None: + item = _extract_item(raw, version) + if item is None: + src.reply(RText(tr("hand_is_empty"), RColor.red)) + return + _send_share(server, src, player, item, version) + return + + with _pending_lock: + _purge_expired_pending() + _pending[player] = (src, version, time.time() + PENDING_TTL) + safe_execute(server, query) + + # If the console never answers (empty hand / command error), tell the player + def _timeout_notice() -> None: + with _pending_lock: + pending = _pending.get(player) + if pending is None: + return + src2, _ver, expire = pending + if time.time() < expire: + return + _pending.pop(player, None) + src2.reply(RText(tr("share_timeout"), RColor.red)) + + timer = threading.Timer(PENDING_TTL + 0.1, _timeout_notice) + timer.daemon = True + timer.start() + + +def handle_info(server: PluginServerInterface, info: Info) -> None: + if info.is_player: + return + match = PLAYER_DATA_RE.match(info.content) + if match is None: + return + name = match.group("name") + with _pending_lock: + pending = _pending.pop(name, None) + if pending is None: + return + src, version, expire = pending + if time.time() >= expire: + src.reply(RText(tr("share_timeout"), RColor.red)) + return + item = _extract_item(match.group(0), version) + if item is None: + src.reply(RText(tr("hand_is_empty"), RColor.red)) + return + _send_share(server, src, name, item, version) + + +def register(server: PluginServerInterface) -> None: + server.register_command(Literal("!!share").runs(lambda src: do_share(server, src))) + server.register_help_message("!!share", tr("share_help")) diff --git a/iridium/utils.py b/iridium/utils.py new file mode 100644 index 0000000..f6d91a2 --- /dev/null +++ b/iridium/utils.py @@ -0,0 +1,142 @@ +import re +import time +from typing import Dict, Optional + +from mcdreforged.api.all import * + +from iridium.version_util import tr + +DATA_PREFIX_RE = re.compile(r"^.+ has the following entity data: ", re.IGNORECASE) +# A payload that looks like an item / entity SNBT, not an error line +SNBT_ITEM_RE = re.compile(r'id\s*:\s*"', re.IGNORECASE) +# Only allow Minecraft-safe name characters +SAFE_NAME_RE = re.compile(r"[^A-Za-z0-9_]") +MAX_NAME_LEN = 32 + +# player -> last success timestamp +_cooldowns: Dict[str, float] = {} + + +def sanitize_name(raw: str) -> str: + """Keep only [A-Za-z0-9_], cap length. Empty string means invalid.""" + return SAFE_NAME_RE.sub("", raw or "").strip()[:MAX_NAME_LEN] + + +def sanitize_cmd(text: str) -> str: + """Neutralize characters that could split a console/RCON command line.""" + # Newlines/carriage returns would create a second command on stdin + return (text or "").replace("\r", " ").replace("\n", " ") + + +def safe_execute(server: PluginServerInterface, command: str) -> None: + server.execute(sanitize_cmd(command)) + + +def check_permission(src: CommandSource, level: int) -> bool: + if src.has_permission(level): + return True + src.reply(RText(tr("required_permission", level), RColor.red)) + return False + + +def require_player(src: CommandSource) -> Optional[str]: + if not src.is_player: + src.reply(RText(tr("player_only"), RColor.red)) + return None + return src.get_info().player + + +def check_cooldown(src: CommandSource, player: str, bucket: str, seconds: int) -> bool: + """Return True if the player may proceed. seconds <= 0 disables the limit.""" + if seconds <= 0: + return True + key = f"{bucket}:{player}" + now = time.time() + last = _cooldowns.get(key) + if last is not None and now - last < seconds: + remain = max(1, int(seconds - (now - last) + 0.999)) + src.reply(RText(tr("on_cooldown", remain), RColor.red)) + return False + return True + + +def mark_cooldown(player: str, bucket: str) -> None: + _cooldowns[f"{bucket}:{player}"] = time.time() + + +def strip_data_prefix(payload: str) -> str: + """Strip the ' has the following entity data: ' prefix.""" + return DATA_PREFIX_RE.sub("", payload, count=1).strip() + + +def looks_like_item_snbt(snbt: str) -> bool: + """True when the text contains an item id field (not a command error line).""" + return bool(snbt) and bool(SNBT_ITEM_RE.search(snbt)) + + +def _read_balanced(text: str, start: int, open_ch: str, close_ch: str) -> Optional[str]: + """Return the balanced substring starting at text[start] == open_ch.""" + if start >= len(text) or text[start] != open_ch: + return None + depth = 0 + for i in range(start, len(text)): + c = text[i] + if c == open_ch: + depth += 1 + elif c == close_ch: + depth -= 1 + if depth == 0: + return text[start : i + 1] + return None + + +def extract_compound(payload: str, key: str) -> Optional[str]: + """ + Extract a top-level field such as SelectedItem from entity NBT text. + Supports compound `{...}` and list `[...]` values. + """ + m = re.search(rf"(?:^|[{{,\s]){re.escape(key)}\s*:", payload) + if m is None: + return None + i = m.end() + while i < len(payload) and payload[i].isspace(): + i += 1 + if i >= len(payload): + return None + if payload[i] == "{": + return _read_balanced(payload, i, "{", "}") + if payload[i] == "[": + return _read_balanced(payload, i, "[", "]") + # primitive value until comma or closing brace/bracket + j = i + while j < len(payload) and payload[j] not in ",}]": + j += 1 + return payload[i:j].strip() + + +def parse_selected_item(entity_nbt: str) -> Optional[str]: + """Pull SelectedItem (or first HandItems entry) out of a full player NBT dump.""" + value = extract_compound(entity_nbt, "SelectedItem") + if value is not None and looks_like_item_snbt(value): + return value + + hand_items = extract_compound(entity_nbt, "HandItems") + if hand_items is None: + return None + # HandItems: [{id:"...",Count:1b},{}] — take the first non-empty item + inner = re.search(r"\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}", hand_items) + if inner is not None: + item = inner.group(0) + if looks_like_item_snbt(item): + return item + return None + + +def rcon_query(server: PluginServerInterface, command: str) -> Optional[str]: + if not server.is_rcon_running(): + return None + try: + return server.rcon_query(command) + except Exception as e: + server.logger.debug(f"RCON query failed: {e}") + return None diff --git a/iridium/version_util.py b/iridium/version_util.py new file mode 100644 index 0000000..f7d7b39 --- /dev/null +++ b/iridium/version_util.py @@ -0,0 +1,50 @@ +from typing import Optional + +from mcdreforged.api.all import * + +from iridium.config import config + +# Fallback strings when ServerInterface is unavailable (unit tests / early import) +_FALLBACK = { + "click_copy": "click to copy data", + "click_suggest": "click for data command", + "calc_usage": "Usage: !!c ", + "share_showing": "is showing", +} + + +def tr(key: str, *args) -> str: + si = ServerInterface.get_instance() + if si is None: + text = _FALLBACK.get(key, key) + if args: + try: + return text.format(*args) + except Exception: + return text + return text + return si.tr(f"iridium.{key}", *args) + + +def get_mc_version(server: PluginServerInterface) -> Optional[Version]: + """Return the detected Minecraft server version, or None.""" + if config.force_version: + try: + return Version(config.force_version) + except Exception: + server.logger.warning(tr("bad_force_version", config.force_version)) + info = server.get_server_information() + if info is None or info.version is None: + return None + return info.version + + +def require_version(server: PluginServerInterface, src: CommandSource) -> Optional[Version]: + version = get_mc_version(server) + if version is None: + src.reply(RText(tr("version_unknown"), RColor.red)) + return version + + +def is_at_least(version: Version, target: str) -> bool: + return version >= Version(target) diff --git a/lang/en_us.yml b/lang/en_us.yml new file mode 100644 index 0000000..1adc39d --- /dev/null +++ b/lang/en_us.yml @@ -0,0 +1,32 @@ +iridium.loaded: Iridium loaded +iridium.unloaded: Iridium unloaded +iridium.version_mode: Version detection mode: {0} +iridium.bad_force_version: Invalid force_version in config: {0} +iridium.version_unknown: Unable to detect Minecraft version. Wait for VersionDetector, or set force_version in config +iridium.player_only: This command can only be used by a player in-game +iridium.required_permission: Insufficient permission, requires level {0} +iridium.on_cooldown: Cooldown, wait {0}s +iridium.hand_is_empty: No usable item in hand +iridium.share_help: Show the item in your main hand (hover to view) +iridium.share_done: Item shared to chat +iridium.share_unsupported_version: Version {0} cannot read held item (needs Minecraft 1.8+) +iridium.share_timeout: Timed out reading held item, try again later +iridium.click_copy: click to copy data +iridium.click_suggest: click for data command +iridium.share_showing: is showing +iridium.hat_help: Swap the item in your hand with your head slot +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.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} +iridium.calc_help: Evaluate an expression, e.g. !!c 1+2*(3-1) +iridium.calc_usage: Usage: !!c , e.g. !!c 1+2*(3-1) +iridium.calc_error: Cannot calculate {0}: {1} +iridium.calc_div_zero: Division by zero +iridium.join_tip_header: Iridium is enabled. Available commands: (click to fill chat) +iridium.join_tip_click: click to fill chat +iridium.motd_header: Server Info +iridium.motd_days: Day {0} since server start diff --git a/lang/zh_cn.yml b/lang/zh_cn.yml new file mode 100644 index 0000000..cbce5ea --- /dev/null +++ b/lang/zh_cn.yml @@ -0,0 +1,32 @@ +iridium.loaded: Iridium 已加载 +iridium.unloaded: Iridium 已卸载 +iridium.version_mode: 版本检测模式: {0} +iridium.bad_force_version: 配置 force_version 无效: {0} +iridium.version_unknown: 无法检测 Minecraft 版本,请等待服务器完成 VersionDetector,或在配置中设置 force_version +iridium.player_only: 该命令只能由玩家在游戏内使用 +iridium.required_permission: 权限不足,需要权限等级 {0} +iridium.on_cooldown: 冷却中,请再等待 {0} 秒 +iridium.hand_is_empty: 手上没有可处理的物品 +iridium.share_help: 展示手上物品的完整信息(悬停查看) +iridium.share_done: 已在聊天栏展示物品 +iridium.share_unsupported_version: 当前版本 {0} 无法读取手持物品(需要 Minecraft 1.8+) +iridium.share_timeout: 读取手持物品超时,请稍后再试 +iridium.click_copy: 点击复制完整数据 +iridium.click_suggest: 点击获取完整数据命令 +iridium.share_showing: 正在展示 +iridium.hat_help: 将手上物品与头部装备互换 +iridium.hat_success: 已交换手上物品与头部装备 +iridium.hat_unsupported_version: 当前版本 {0} 不支持 !!hat(需要 Minecraft 1.8+) +iridium.hat_need_rcon: !!hat 需要启用 RCON 才能读取并交换物品数据 +iridium.hat_query_failed: 读取玩家物品数据失败 +iridium.head_help: 获取一个玩家头颅(可指定玩家名,冷却 {0} 秒) +iridium.head_help_short: 获取一个玩家头颅(可指定玩家名) +iridium.head_success: 已给予你 {0} 的头颅 +iridium.calc_help: 计算表达式,例如 !!c 1+2*(3-1) +iridium.calc_usage: 用法: !!c <表达式>,例如 !!c 1+2*(3-1) +iridium.calc_error: 无法计算 {0}: {1} +iridium.calc_div_zero: 除数不能为 0 +iridium.join_tip_header: 本服已启用 Iridium,可用命令:(点击命令可填入聊天框) +iridium.join_tip_click: 点击填入聊天栏 +iridium.motd_header: 服务器信息 +iridium.motd_days: 开服第 {0} 天 diff --git a/mcdreforged.plugin.json b/mcdreforged.plugin.json new file mode 100644 index 0000000..664c674 --- /dev/null +++ b/mcdreforged.plugin.json @@ -0,0 +1,18 @@ +{ + "id": "iridium", + "version": "26.9.1", + "name": "Iridium", + "description": { + "zh_cn": "物品展示 / 戴头 / 获取头颅 / 游戏内计算器 / 进服 MOTD", + "en_us": "Item share / hat / player head / calculator / join MOTD" + }, + "author": [ + "云镜之端/SKYMirror" + ], + "dependencies": { + "mcdreforged": ">=2.6.0" + }, + "resources": [ + "lang" + ] +} diff --git a/pack.sh b/pack.sh new file mode 100755 index 0000000..6da1012 --- /dev/null +++ b/pack.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# Pack Iridium.mcdr for release. Run from repo root. +set -euo pipefail +cd "$(dirname "$0")" +rm -f Iridium.mcdr +zip -r Iridium.mcdr iridium lang mcdreforged.plugin.json LICENSE \ + -x '*__pycache__*' -x '*.pyc' +echo "Built Iridium.mcdr" diff --git a/tests/test_pure.py b/tests/test_pure.py new file mode 100644 index 0000000..6bd28c6 --- /dev/null +++ b/tests/test_pure.py @@ -0,0 +1,253 @@ +"""Pure-function unit tests (no live MCDR server required).""" +import sys +import os + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from mcdreforged.api.all import Version + +from iridium.calculator import CalcError, safe_eval +from iridium.hat import ( + _components_to_command_inner, + _extract_head_item, + _hand_slot, + _head_slot, + _item_to_command_form, + _is_air, +) +from iridium.head import _give_head_command +from iridium.utils import ( + extract_compound, + looks_like_item_snbt, + parse_selected_item, + sanitize_cmd, + sanitize_name, + strip_data_prefix, +) + + +def test_strip_prefix(): + payload = 'Notch has the following entity data: {id:"minecraft:stone",Count:1b}' + assert strip_data_prefix(payload) == '{id:"minecraft:stone",Count:1b}' + + +def test_parse_selected_item(): + nbt = '{SelectedItem:{id:"minecraft:diamond_sword",Count:1b,tag:{Damage:1}},SelectedItemSlot:0}' + assert parse_selected_item(nbt) == '{id:"minecraft:diamond_sword",Count:1b,tag:{Damage:1}}' + + +def test_extract_compound(): + nbt = '{foo:1,SelectedItem:{id:"minecraft:apple",Count:3b},bar:2}' + assert extract_compound(nbt, "SelectedItem") == '{id:"minecraft:apple",Count:3b}' + + +def test_extract_list_field(): + nbt = '{HandItems:[{id:"minecraft:stone",Count:1b},{}]}' + value = extract_compound(nbt, "HandItems") + assert value is not None + assert value.startswith("[") + assert "minecraft:stone" in value + + +def test_parse_hand_items_fallback(): + nbt = '{HandItems:[{id:"minecraft:stone",Count:2b},{}]}' + item = parse_selected_item(nbt) + assert item == '{id:"minecraft:stone",Count:2b}' + + +def test_looks_like_item_snbt(): + assert looks_like_item_snbt('{id:"minecraft:stone",Count:1b}') + assert not looks_like_item_snbt("No data found for SelectedItem") + assert not looks_like_item_snbt("") + assert not looks_like_item_snbt("{}") + + +def test_head_commands(): + assert ( + _give_head_command("Steve", "Notch", Version("1.20.5")) + == 'give Steve minecraft:player_head[minecraft:profile="Notch"] 1' + ) + assert ( + _give_head_command("Steve", "Notch", Version("1.16.5")) + == 'give Steve minecraft:player_head{SkullOwner:"Notch"} 1' + ) + assert ( + _give_head_command("Steve", "Notch", Version("1.12.2")) + == 'give Steve minecraft:skull 1 3 {SkullOwner:"Notch"}' + ) + assert ( + _give_head_command("Steve", 'Evil"name', Version("1.16.5")) + == 'give Steve minecraft:player_head{SkullOwner:"Evilname"} 1' + ) + assert ( + _give_head_command("Steve", 'Notch;give @a diamond', Version("1.16.5")) + == 'give Steve minecraft:player_head{SkullOwner:"Notchgiveadiamond"} 1' + ) + # component / NBT breakout attempts + assert ( + _give_head_command("Steve", 'alex"}{id:"minecraft:stone', Version("1.16.5")) + == 'give Steve minecraft:player_head{SkullOwner:"alexidminecraftstone"} 1' + ) + assert ( + _give_head_command("Steve", 'x[minecraft:profile=y]', Version("1.20.5")) + == 'give Steve minecraft:player_head[minecraft:profile="xminecraftprofiley"] 1' + ) + # executor name is sanitized too + assert ( + _give_head_command('Steve";give', "Notch", Version("1.16.5")) + == 'give Stevegive minecraft:player_head{SkullOwner:"Notch"} 1' + ) + # length cap + assert ( + _give_head_command("Steve", "A" * 100, Version("1.16.5")) + == 'give Steve minecraft:player_head{SkullOwner:"' + "A" * 32 + '"} 1' + ) + + +def test_item_form_modern(): + part, count = _item_to_command_form( + '{id:"minecraft:diamond",count:2,components:{"minecraft:custom_name":"x"}}', + Version("1.20.5"), + ) + assert part == 'minecraft:diamond[minecraft:custom_name="x"]' + assert count == 2 + + +def test_components_to_command_inner(): + inner = _components_to_command_inner( + '{"minecraft:custom_name":{text:"hi"}, "minecraft:damage":1, "minecraft:enchantments":{}}' + ) + assert 'minecraft:custom_name={text:"hi"}' in inner + assert "minecraft:damage=1" in inner + assert "minecraft:enchantments={}" in inner + + +def test_item_form_legacy(): + part, count = _item_to_command_form( + '{id:"minecraft:stone",Count:1b,tag:{display:{Name:"hi"}}}', + Version("1.12.2"), + ) + assert part == 'minecraft:stone{display:{Name:"hi"}}' + assert count == 1 + + +def test_slots(): + assert _head_slot(Version("1.20.1")) == "armor.head" + assert _head_slot(Version("1.12.2")) == "slot.armor.head" + assert _hand_slot(Version("1.20.1")) == "weapon.mainhand" + assert _hand_slot(Version("1.12.2")) == "slot.weapon" + assert _hand_slot(Version("1.8.9"), 3) == "slot.hotbar.3" + assert _hand_slot(Version("1.8.9")) == "slot.hotbar.0" + + +def test_head_extract_1_12(): + payload = ( + 'Steve has the following entity data: ' + '{SelectedItem:{id:"minecraft:stone",Count:1b},' + 'Inventory:[{Slot:103b,id:"minecraft:iron_helmet",Count:1b},{Slot:0b,id:"minecraft:dirt",Count:1b}]}' + ) + item = _extract_head_item(payload, Version("1.12.2")) + assert item is not None + assert "iron_helmet" in item + + +def test_is_air(): + assert _is_air(None) + assert _is_air("{}") + assert _is_air('{id:"minecraft:air"}') + assert not _is_air('{id:"minecraft:stone",Count:1b}') + + +def test_calculator(): + assert safe_eval("1+2*3") == 7 + assert safe_eval("(1+2)*(3-1)") == 6 + assert abs(safe_eval("10/4") - 2.5) < 1e-9 + assert safe_eval("2**10") == 1024 + assert safe_eval("2^10") == 1024 + try: + safe_eval("__import__('os')") + raise AssertionError("should reject") + except CalcError: + pass + try: + safe_eval("1;print(1)") + raise AssertionError("should reject") + except CalcError: + pass + + +class _DummyServer: + def get_server_information(self): + return None + + +def test_motd_days_and_links(): + from datetime import datetime + + from iridium import join_tip + from iridium.config import Config, apply_config + + cfg = Config() + cfg.motd_start_day = "2020-01-01" + cfg.motd_enabled = True + apply_config(cfg) + days = join_tip.server_days() + assert days is not None + assert days == (datetime.now() - datetime(2020, 1, 1)).days + 1 + + pieces = join_tip.render_motd_line( + "§e欢迎 §f{player}§e,官网 {link:§b打开|https://example.com}", + "Steve", + _DummyServer(), + ) + assert pieces + joined = "".join(p.to_plain_text() for p in pieces) + assert "Steve" in joined + assert "打开" in joined + + +def test_motd_placeholder_replacement(): + from iridium import join_tip + from iridium.config import Config, apply_config + + cfg = Config() + cfg.motd_start_day = "" + cfg.motd_lines = ["hi {player} / {days} / {online}"] + apply_config(cfg) + text = join_tip.replace_placeholders("hi {player} / {days} / {online}", "Alex", _DummyServer()) + assert text == "hi Alex / ? / ?" + + +def test_sanitize_name_and_cmd(): + assert sanitize_name("Steve") == "Steve" + assert sanitize_name('Evil";give') == "Evilgive" + assert sanitize_name("A" * 100) == "A" * 32 + assert sanitize_name("") == "" + assert sanitize_cmd("give x\ngive y") == "give x give y" + assert sanitize_cmd("a\r\nb") == "a b" + + +def test_share_query_sanitizes_player(): + from mcdreforged.api.all import Version + + from iridium.share import _build_tellraw, _query_command + + cmd = _query_command('Steve";op', Version("1.16.5")) + assert 'Steveop' in cmd + assert '";' not in cmd + tr = _build_tellraw('Bad"name', '{id:"minecraft:stone",Count:1b}', Version("1.20.1")) + assert "Badname" in tr + + +if __name__ == "__main__": + fns = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)] + failed = 0 + for fn in fns: + try: + fn() + print(f"PASS {fn.__name__}") + except Exception as e: + failed += 1 + print(f"FAIL {fn.__name__}: {e}") + print(f"\n{len(fns) - failed}/{len(fns)} passed") + sys.exit(1 if failed else 0)