发布 Iridium 26.9.1
MCDR plugin: item share, hat, player head, calculator, join MOTD. All rights reserved. Author: 云镜之端/SKYMirror
This commit is contained in:
commit
09c2392b99
18 files changed
+1796
No files matched your search
@@ -0,0 +1,8 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.egg-info/
|
||||||
|
.pytest_cache/
|
||||||
|
config/
|
||||||
|
logs/
|
||||||
|
.DS_Store
|
||||||
|
Iridium.mcdr
|
||||||
@@ -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://`
|
||||||
@@ -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.
|
||||||
@@ -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)。
|
||||||
@@ -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"))
|
||||||
@@ -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"))
|
||||||
@@ -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
@@ -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"))
|
||||||
@@ -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))
|
||||||
@@ -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)
|
||||||
@@ -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"))
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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} 天
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
@@ -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)
|
||||||
Reference in new issue
Block a user