release: Iridium 26.9.1

MCDR plugin: item share, hat, player head, calculator, join MOTD.
All rights reserved. Author: 云镜之端/SKYMirror
This commit is contained in:
SKYMirror committed 2026-09-13 02:32:54 +08:00
commit d5ba9743ed
18 files changed
+1796

No files matched your search

+8
View File
@@ -0,0 +1,8 @@
__pycache__/
*.py[cod]
*.egg-info/
.pytest_cache/
config/
logs/
.DS_Store
Iridium.mcdr
+20
View File
@@ -0,0 +1,20 @@
# Changelog
本文件记录 Iridium 的重要变更。
## [26.9.1] - 2026-09-13
### Added
- `!!share`:展示主手物品(悬停完整数据,1.16+ 点击复制,可选提示音)
- `!!hat`:主手物品与头部装备互换(需 RCON)
- `!!head [player]`:获取一个玩家头颅,支持冷却与权限
- `!!c <expression>`:游戏内四则运算计算器(AST 白名单,零依赖)
- 进服 MOTD:开服天数、自定义文案、可点击链接、`§` 颜色
- 进服命令提示(可点击填入聊天框)
- 按 Minecraft 版本分支;不支持的功能会明确提示
### Security
- 玩家名统一清洗为 `[A-Za-z0-9_]` 并限长后再拼进 `/give`、`data get`、`item replace`
- 控制台命令去除换行,防止拆成多行二次执行
- tellraw 对物品 SNBT 做 JSON 转义与长度截断
- MOTD 链接仅允许 `http://` / `https://`
+14
View File
@@ -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.
+169
View File
@@ -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 <player>` | `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)。
+39
View File
@@ -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"))
+112
View File
@@ -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 <expression>", do_calc)
builder.arg("expression", GreedyText)
builder.register(server)
server.register_help_message("!!c <expression>", tr("calc_help"))
+56
View File
@@ -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)
+336
View File
@@ -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"))
+52
View File
@@ -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 <player_name>", 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))
+242
View File
@@ -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<text>[^|}]+)\|(?P<url>[^}]+)\}")
LINK_URL_ONLY_RE = re.compile(r"\{link:(?P<url>[^}|]+)\}")
_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)
+213
View File
@@ -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<name>\S+) has the following entity data: (?P<data>.*)$",
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"))
+142
View File
@@ -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 '<name> 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
+50
View File
@@ -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 <expression>",
"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)
+32
View File
@@ -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 <expression>, 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
+32
View File
@@ -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} 天
+18
View File
@@ -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"
]
}
Executable
+8
View File
@@ -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"
+253
View File
@@ -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)