diff --git a/CHANGELOG.md b/CHANGELOG.md index 9dc940e..b9a7500 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ This file records notable changes to Iridium. --- +## [26.9.16] - 2026-09-14 + +### 变更 / Changed +- 配置格式改为 **YAML**:`config/iridium/iridium.yml`(`#` 原生注释) +- Config format is now **YAML**: `config/iridium/iridium.yml` (native `#` comments) +- 自动从旧 JSON(`config/iridium.json` 或 `config/iridium/iridium.json`)迁移 +- Auto-migrates from legacy JSON configs + +--- + ## [26.9.15] - 2026-09-14 ### 修复 / Fixed diff --git a/README.md b/README.md index 0e4fe4f..353b8c5 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ MCDReforged 插件:物品展示 / 戴头 / 获取头颅 / 游戏内计算器 / | | | |---|---| -| 版本 | **26.9.15** | +| 版本 | **26.9.16** | | 作者 | 云镜之端/SKYMirror | | 许可 | 保留所有权益(All Rights Reserved) | | 前置 | MCDReforged `>= 2.6.0` | @@ -79,26 +79,23 @@ rcon.password=your_password ## 配置 -路径:`config/iridium/iridium.json`(首次加载自动生成;若存在旧文件 `config/iridium.json` 会自动迁移) +路径:`config/iridium/iridium.yml`(YAML,`#` 注释;首次加载自动生成;旧的 `config/iridium.json` / `config/iridium/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": "2026-09-14", - "motd_lines": [ - "§6欢迎 §e{player}§6 加入服务器!", - "§7服务器已开服 §b{days}§7 天", - "§7官网: {link:§b点击打开官网|https://www.skymirror.top}", - "§7QQ群: {link:§f985402607|https://skymirror.top/qq}" - ] -} +```yaml +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: "2026-09-14" +motd_lines: + - "§6欢迎 §e{player}§6 加入服务器!" + - "§7服务器已开服 §b{days}§7 天" + - "§7官网: {link:§b点击打开官网|https://www.skymirror.top}" + - "§7QQ群: {link:§f985402607|https://skymirror.top/qq}" ``` | 字段 | 默认 | 说明 | diff --git a/README_en.md b/README_en.md index 25dd955..4f1d7ec 100644 --- a/README_en.md +++ b/README_en.md @@ -4,7 +4,7 @@ MCDReforged plugin: item share / wear on head / player head / calculator / join | | | |---|---| -| Version | **26.9.15** | +| Version | **26.9.16** | | Author | 云镜之端/SKYMirror | | License | All Rights Reserved | | Requires | MCDReforged `>= 2.6.0` | @@ -48,7 +48,7 @@ rcon.password=your_password ## Config -Path: `config/iridium/iridium.json` (auto-created on first load; migrates legacy `config/iridium.json` if present) +Path: `config/iridium/iridium.yml` (YAML with `#` comments; auto-created on first load; migrates legacy JSON configs) | Field | Default | Description | |------|---------|-------------| diff --git a/iridium/config.py b/iridium/config.py index 57c9de9..b4ea171 100644 --- a/iridium/config.py +++ b/iridium/config.py @@ -1,11 +1,17 @@ import json import shutil from datetime import date +from io import StringIO from pathlib import Path -from typing import List, Optional +from typing import Any, List, Optional from mcdreforged.api.all import * +try: + from ruamel.yaml import YAML +except ImportError: # pragma: no cover + YAML = None # type: ignore + def _today() -> str: return date.today().strftime("%Y-%m-%d") @@ -39,57 +45,60 @@ class Config(Serializable): ] -# MCDR data folder: config//iridium.json -CONFIG_FILE_NAME = "iridium.json" -# 旧路径(仅用于迁移) -LEGACY_CONFIG_FILE = "config/iridium.json" +# 当前配置:MCDR 数据目录 → config/iridium/iridium.yml +CONFIG_FILE_NAME = "iridium.yml" +# 历史路径(仅用于迁移) +LEGACY_JSON_NAMES = ( + "iridium.json", # config/iridium/iridium.json +) def build_default_config_text() -> str: - """带注释的默认配置;开服日期为生成当天。""" + """带注释的默认 YAML 配置;开服日期为生成当天。""" today = _today() - return f"""{{ - // 使用 !!share / !!hat / !!head / !!c 所需的最低权限等级 - // 0=控制台 1=普通玩家 2=OP/管理员 - "permission": 1, + return f"""# Iridium 配置文件 (YAML) +# 修改后执行: !!MCDR reload plugin +# This file uses standard YAML. '#' starts a comment. - // !!head 冷却时间(秒),0 表示不限制 - "head_cooldown_seconds": 60, +# 使用 !!share / !!hat / !!head / !!c 所需的最低权限等级 +# 0=控制台 1=普通玩家 2=OP/管理员 +permission: 1 - // !!hat 冷却时间(秒),0 表示不限制 - "hat_cooldown_seconds": 3, +# !!head 冷却时间(秒),0 表示不限制 +head_cooldown_seconds: 60 - // !!share 是否播放提示音 - "share_sound": true, +# !!hat 冷却时间(秒),0 表示不限制 +hat_cooldown_seconds: 3 - // 强制指定 Minecraft 版本(如 "1.12.2"、"1.20.1") - // 留空则自动检测;检测失败时可手动填写 - "force_version": "", +# !!share 是否播放提示音 +share_sound: true - // 玩家进服时是否提示可用命令 - "join_tip": true, +# 强制指定 Minecraft 版本(如 "1.12.2"、"1.20.1") +# 留空则自动检测;检测失败时可手动填写 +force_version: "" - // 进服消息延迟(秒),0 为立即发送 - "join_tip_delay_seconds": 1.5, +# 玩家进服时是否提示可用命令 +join_tip: true - // 是否发送进服 MOTD(开服天数 + 自定义文案) - "motd_enabled": true, +# 进服消息延迟(秒),0 为立即发送 +join_tip_delay_seconds: 1.5 - // 开服日期,格式 YYYY-MM-DD(当天算第 1 天) - // 默认为本配置文件生成当天,可自行修改为真实开服日 - "motd_start_day": "{today}", +# 是否发送进服 MOTD(开服天数 + 自定义文案) +motd_enabled: true - // MOTD 文本行 - // 占位符: {{player}} 玩家名 {{days}} 开服天数 {{online}} 在线人数 - // 链接: {{link:显示文字|https://url}} 或 {{link:https://url}} - // 颜色: 支持原版 § 颜色代码 - "motd_lines": [ - "§6欢迎 §e{{player}}§6 加入服务器!", - "§7服务器已开服 §b{{days}}§7 天", - "§7官网: {{link:§b点击打开官网|https://www.skymirror.top}}", - "§7QQ群: {{link:§f985402607|https://skymirror.top/qq}}" - ] -}} +# 开服日期,格式 YYYY-MM-DD(当天算第 1 天) +# 默认为本配置文件生成当天,可自行修改为真实开服日 +motd_start_day: "{today}" + +# MOTD 文本行 +# 占位符: {{player}} 玩家名 {{days}} 开服天数 {{online}} 在线人数 +# 链接: {{link:显示文字|https://url}} 或 {{link:https://url}} +# 颜色: 支持原版 § 颜色代码 +motd_lines: + - "§6欢迎 §e{{player}}§6 加入服务器!" + - "§7服务器已开服 §b{{days}}§7 天" + - "§7官网: {{link:§b点击打开官网|https://www.skymirror.top}}" + - "§7QQ群: {{link:§f985402607|https://skymirror.top/qq}}" """ @@ -112,8 +121,26 @@ def apply_config(new_cfg: Config) -> None: config.motd_lines = list(new_cfg.motd_lines) +def _yaml(): + if YAML is None: + raise RuntimeError("ruamel.yaml is required") + return YAML(typ="safe") + + +def _load_yaml_text(text: str) -> Any: + return _yaml().load(StringIO(text)) + + +def _dump_yaml(data: dict) -> str: + buf = StringIO() + y = _yaml() + y.default_flow_style = False + y.dump(data, buf) + return buf.getvalue() + + def _strip_json_comments(text: str) -> str: - """Remove // line comments outside of strings.""" + """Remove // line comments outside of strings (legacy JSON only).""" out = [] i = 0 n = len(text) @@ -145,19 +172,28 @@ def _strip_json_comments(text: str) -> str: def config_file_path(server: PluginServerInterface) -> Path: - """Canonical path: MCDR data folder → config/iridium/iridium.json""" + """Canonical path: MCDR data folder → config/iridium/iridium.yml""" return Path(server.get_data_folder()) / CONFIG_FILE_NAME -def _legacy_path(server: PluginServerInterface) -> Path: +def _working_dir(server: PluginServerInterface) -> Path: try: wd = server.get_mcdr_config().get("working_directory") or "." - return (Path(wd) / LEGACY_CONFIG_FILE).expanduser().resolve() + return Path(wd).expanduser() except Exception: - return Path.cwd() / LEGACY_CONFIG_FILE + return Path.cwd() -def _write_commented_default(path: Path, server: PluginServerInterface) -> bool: +def _legacy_json_paths(server: PluginServerInterface) -> List[Path]: + data_dir = Path(server.get_data_folder()) + wd = _working_dir(server) + return [ + data_dir / "iridium.json", + wd / "config" / "iridium.json", + ] + + +def _write_default(path: Path, server: PluginServerInterface) -> bool: try: path.parent.mkdir(parents=True, exist_ok=True) path.write_text(build_default_config_text(), encoding="utf-8") @@ -168,49 +204,78 @@ def _write_commented_default(path: Path, server: PluginServerInterface) -> bool: return False -def _migrate_legacy(server: PluginServerInterface, dest: Path) -> None: - """If old config/iridium.json exists, copy it to the data folder path.""" - src = _legacy_path(server) - try: - if src.is_file() and src.resolve() != dest.resolve() and not dest.is_file(): +def _try_load_dict_from_text(text: str) -> Optional[dict]: + text = text.strip() + if not text: + return None + if text.startswith("{"): + return json.loads(_strip_json_comments(text)) + data = _load_yaml_text(text) + return data if isinstance(data, dict) else None + + +def _migrate_legacy_json(server: PluginServerInterface, dest: Path) -> None: + """Convert old JSON config (with optional // comments) to YAML if dest is missing.""" + if dest.is_file(): + return + for src in _legacy_json_paths(server): + try: + if not src.is_file(): + continue + raw = src.read_text(encoding="utf-8") + data = _try_load_dict_from_text(raw) + if not data: + continue dest.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(src, dest) - server.logger.info(f"已从旧路径迁移配置: {src} -> {dest}") - except Exception as e: - server.logger.warning(f"迁移旧配置失败 {src}: {e}") + # keep comments in default template + overlay known keys via dump + cfg = Config.deserialize(data) + overlay = { + "permission": cfg.permission, + "head_cooldown_seconds": cfg.head_cooldown_seconds, + "hat_cooldown_seconds": cfg.hat_cooldown_seconds, + "share_sound": cfg.share_sound, + "force_version": cfg.force_version, + "join_tip": cfg.join_tip, + "join_tip_delay_seconds": cfg.join_tip_delay_seconds, + "motd_enabled": cfg.motd_enabled, + "motd_start_day": cfg.motd_start_day, + "motd_lines": list(cfg.motd_lines), + } + # Prefer commented default template when values match defaults is complex; + # dump parsed values with a short header for clarity. + header = ( + "# Iridium 配置 (从旧 JSON 迁移)\n" + "# Migrated from legacy JSON config\n\n" + ) + dest.write_text(header + _dump_yaml(overlay), encoding="utf-8") + server.logger.info(f"已从旧 JSON 迁移配置: {src} -> {dest}") + return + except Exception as e: + server.logger.warning(f"迁移旧配置失败 {src}: {e}") def load_config(server: PluginServerInterface) -> Config: """ - Load config using MCDR's official data folder (config/iridium/iridium.json). - 1. Prefer that path; create commented default if missing - 2. Migrate legacy config/iridium.json if present - 3. Fallback: load_config_simple + Load config/iridium/iridium.yml (MCDR data folder). + Create commented default if missing; migrate legacy JSON if needed. """ path = config_file_path(server) - _migrate_legacy(server, path) + _migrate_legacy_json(server, path) if not path.is_file(): - _write_commented_default(path, server) + _write_default(path, server) - if path.is_file(): - try: - raw = path.read_text(encoding="utf-8") - data = json.loads(_strip_json_comments(raw)) - server.logger.info(f"配置已加载: {path}") - return Config.deserialize(data) - except Exception as e: - server.logger.error(f"读取/解析配置失败 {path}: {e},尝试 load_config_simple") + if not path.is_file(): + server.logger.warning(f"配置文件不存在且无法创建,使用内存默认值: {path}") + return Config() - # Official MCDR loader as last resort try: - cfg = server.load_config_simple( - CONFIG_FILE_NAME, - target_class=Config, - in_data_folder=True, - ) - server.logger.info(f"配置已通过 load_config_simple 加载: {path}") - return cfg + raw = path.read_text(encoding="utf-8") + data = _load_yaml_text(raw) + if not isinstance(data, dict): + raise ValueError("YAML root must be a mapping") + server.logger.info(f"配置已加载: {path}") + return Config.deserialize(data) except Exception as e: - server.logger.error(f"load_config_simple 失败: {e},使用内存默认值") + server.logger.error(f"读取/解析 YAML 配置失败 {path}: {e},使用默认值") return Config() diff --git a/mcdreforged.plugin.json b/mcdreforged.plugin.json index dddcfae..755ac75 100644 --- a/mcdreforged.plugin.json +++ b/mcdreforged.plugin.json @@ -1,6 +1,6 @@ { "id": "iridium", - "version": "26.9.15", + "version": "26.9.16", "name": "Iridium", "description": { "zh_cn": "物品展示 / 戴头 / 获取头颅 / 游戏内计算器 / 进服 MOTD", diff --git a/tests/test_pure.py b/tests/test_pure.py index 8933b60..cbb712b 100644 --- a/tests/test_pure.py +++ b/tests/test_pure.py @@ -1,6 +1,7 @@ """Pure-function unit tests (no live MCDR server required).""" -import sys +import json import os +import sys sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) @@ -268,20 +269,35 @@ def test_extract_display_name(): def test_strip_json_comments(): - import json from datetime import date - from iridium.config import build_default_config_text, _strip_json_comments + from iridium.config import _strip_json_comments, build_default_config_text - text = build_default_config_text() - parsed = json.loads(_strip_json_comments(text)) - assert parsed["permission"] == 1 - assert "skymirror.top" in parsed["motd_lines"][2] - assert parsed["motd_start_day"] == date.today().strftime("%Y-%m-%d") - # comment-looking text inside string is kept + # legacy JSON comment stripper still works + assert json.loads(_strip_json_comments('{"a": 1 // x\n}'))["a"] == 1 assert json.loads(_strip_json_comments('{"a": "http://x"}'))["a"] == "http://x" +def test_default_yaml_config(): + from datetime import date + + from iridium.config import _load_yaml_text, build_default_config_text + + data = _load_yaml_text(build_default_config_text()) + assert data["permission"] == 1 + assert data["motd_start_day"] == date.today().strftime("%Y-%m-%d") + assert any("skymirror.top" in line for line in data["motd_lines"]) + assert any("skymirror.top/qq" in line for line in data["motd_lines"]) + + +def test_legacy_json_to_yaml_parse(): + from iridium.config import _try_load_dict_from_text + + assert _try_load_dict_from_text('{"permission": 2}')["permission"] == 2 + assert _try_load_dict_from_text('{\n // c\n "permission": 3\n} ')["permission"] == 3 + assert _try_load_dict_from_text("permission: 4\nmotd_start_day: '2020-01-01'\n")["permission"] == 4 + + if __name__ == "__main__": fns = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)] failed = 0