From 13c36adcd18ea15e531e61d65914bae7bda946aa Mon Sep 17 00:00:00 2001 From: 401U Date: Sun, 4 Oct 2026 03:25:09 +0800 Subject: [PATCH 1/4] 401U-8: add voting, verified backups, force requests and stable ordering Co-authored-by: multica-agent --- .gitignore | 4 + README.md | 6 +- README_en.md | 6 +- docs/migration.md | 122 +++++ docs/verification.md | 81 +++ lang/en_us.yml | 50 ++ lang/zh_cn.yml | 50 ++ mcdreforged.plugin.json | 3 +- mount/MountManager.py | 881 ++++++++++++++++++++------------ mount/MountSlot.py | 131 ++--- mount/backup_helper.py | 101 ++++ mount/cmd_tree.py | 146 +++--- mount/config.py | 37 +- mount/entry.py | 97 ++-- mount/list_order.py | 6 + mount/players.py | 89 ++++ mount/reset_helper.py | 98 ++-- mount/vote.py | 34 ++ requirements-test-2.7.txt | 5 + requirements-test.txt | 4 + tests/conftest.py | 135 +++++ tests/isolated_mcdr.py | 168 ++++++ tests/test_commands_stats.py | 120 +++++ tests/test_files_and_players.py | 100 ++++ tests/test_more_failures.py | 136 +++++ tests/test_operations.py | 134 +++++ tests/test_validation.py | 83 +++ 27 files changed, 2239 insertions(+), 588 deletions(-) create mode 100644 docs/migration.md create mode 100644 docs/verification.md create mode 100644 mount/backup_helper.py create mode 100644 mount/list_order.py create mode 100644 mount/players.py create mode 100644 mount/vote.py create mode 100644 requirements-test-2.7.txt create mode 100644 requirements-test.txt create mode 100644 tests/conftest.py create mode 100644 tests/isolated_mcdr.py create mode 100644 tests/test_commands_stats.py create mode 100644 tests/test_files_and_players.py create mode 100644 tests/test_more_failures.py create mode 100644 tests/test_operations.py create mode 100644 tests/test_validation.py diff --git a/.gitignore b/.gitignore index 90feab8..6e150dd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,8 @@ .idea +.venv/ +.venv-*/ +.pytest_cache/ +isolated-mcdr.log *.mcdr *.pyz */__pycache__/ diff --git a/README.md b/README.md index 6643e2f..9721442 100644 --- a/README.md +++ b/README.md @@ -78,4 +78,8 @@ ## 其他 - 在自动检测目录的子目录下添加名为`.mount-ignore`的文件可以使该子目录免于检测 - 通过手动修改配置文件, 可以添加任意目录的服务器作为挂载点 -- 实际配置格式均需要满足json格式,即不得包含上例中以`//`开头的注释 \ No newline at end of file +- 实际配置格式均需要满足json格式,即不得包含上例中以`//`开头的注释 + +## 投票、备份与排序升级 + +本分支将切换/重置统一为在线比例投票,新增授权者直接备份、指定名单本人确认强制操作与置顶/活跃度排序。旧配置安全迁移,详见 [迁移与命令说明](docs/migration.md) 和 [隔离验证说明](docs/verification.md)。 diff --git a/README_en.md b/README_en.md index 462ef40..f126067 100644 --- a/README_en.md +++ b/README_en.md @@ -78,4 +78,8 @@ ## Other - add file with name `.mount-ignore` under folder in auto-detect folder to not detect that folder - by editing config file, you can add any server in any folder as mountable server -- the actual config file must be json format, so remove the comments starting with `//` from above config sample \ No newline at end of file +- the actual config file must be json format, so remove the comments starting with `//` from above config sample + +## Voting, backup and ordering upgrade + +Switch/reset now use online-player voting. Authorized users can back up directly; configured force users personally confirm their action. Lists support pinning and optional trusted activity ordering. See [migration and commands](docs/migration.md) and [isolated verification](docs/verification.md). diff --git a/docs/migration.md b/docs/migration.md new file mode 100644 index 0000000..d911fae --- /dev/null +++ b/docs/migration.md @@ -0,0 +1,122 @@ +# Mount voting and backup migration / 投票与备份迁移 + +## 行为变化 + +旧配置可继续加载,新增字段采用安全默认值。`!!mount <完整路径>` 和尾随 +`--confirm` 均只创建切换投票;`--reset/-rs` 创建重置投票。单独 `--confirm` +只显示投票进度,不投赞成票也不执行操作。`--abort` 仅取消有权取消的当前投票。 +新按钮全部绑定完整 UUID,旧会话按钮不会操作新会话。`!!m` 别名按原配置保留。 + +切换默认 60%、重置默认 100%、60 秒超时。冻结在线分母,门槛向上取整,发起者 +必须显式投票,一人一票且不能改票。加入不增员、离开不降低门槛;已经离线的玩家 +不能继续投票。普通投票仅受信任玩家命令源可发起,控制台只看进度。 +同实例同类投票从上次终结起冷却 60 秒。 + +运行中加载、重载及切服后查询 `list` 恢复完整在线集合。只支持 vanilla/bukkit +英文在线列表格式;超时、人数不匹配或其他 handler 安全拒绝投票,单次进出事件 +不恢复信任。已收到但尚未处理的旧输出通过 MCDR 单调 Info.id 屏障过滤。 +查询不重叠,过期/卸载前的查询必须先丢弃旧回复再重查;完全不回复时持续拒绝投票,直到新服务会话。 +查询期间的进出事件合并进快照。服务器身份依赖其认证方式,离线模式同名冒用 +风险必须由部署者处理;不要将普通聊天字符串作为强制名单身份来源。 + +## 新增配置 + +在原 Mount 实例配置文件中设置,重载生效: + +| 字段 | 默认值 / 说明 | +|---|---| +| `pinned_servers` | `[]`;完整路径,按置顶名单顺序优先 | +| `list_order` | `configured`;可选 `activity`,只使用新可信累计玩家时长,同分稳定 | +| `switch_ratio`, `reset_ratio` | `0.6`, `1.0`;范围 `(0,1]` | +| `vote_timeout`, `vote_cooldown` | `60`, `60` 秒 | +| `backup_permission`, `cancel_permission` | `3`, `3`;MCDR 0–4 等级 | +| `force_players`, `force_console` | `[]`, `false`;按认证玩家名精确匹配,名单不附带其他权限 | +| `force_timeout` | `60` 秒,所有强制操作本人确认有效期 | +| `backup_root` | `../mount-backups`;须与实例、源世界和重置模板隔离 | +| `backup_worlds` | `world/world_nether/world_the_end`;可配置实例内相对路径 | +| `stop_timeout`, `start_timeout` | `60`, `120` 秒 | + +非法分页大小回退 15;非法比例、权限、计时或排序模式拒绝加载,修正配置后重载。 +旧 `SlotStats` 字段保留供审计,不参与排名;新 `use_time_ns_v2` 和 +`player_time_ns_v2` 从零建立可信基线,单调时钟累计。不持久化玩家姓名名单。 + +## 操作命令 + +`!!mount switch <完整路径>` 支持空格。`reset` 发起当前服重置投票; +`vote yes|no` 投票,`status [id]` 查看,`cancel ` 由发起者或管理权限取消。 +`list [page]` 先排序再分页,不可用目标保留显示。 + +授权者看到独立 `backup` 按钮,点击直接开始,无确认、无投票、不切换或重置。 +服务器原在运行则完全停服后复制并尝试恢复;原已停机则保持停机。 +至少一个世界存在才备份,缺失世界列入 manifest。逐文件相对路径、大小、SHA-256 +及空目录校验一致后,写 manifest 并在同一文件系统内将临时目录改名提交。 +不自动删除历史备份、不提供恢复命令、不备份插件数据;自行安排容量与人工恢复。 +符号链接、Windows 重解析点、世界/模板/目的包含关系拒绝。 +启动命令发出不等于启动完成,结果分别报告文件操作与真实启动状态。 +备份结果仅向发起者及管理日志显示;掉线结果限本次运行期保存并在回服后补发。 + +`force switch <完整路径>`、`force reset` 仅名单用户可用;均需 +`force confirm ` 本人确认。确认摘要绑定操作者、完整目标、配置与原投票 ID。 +确认前原投票继续;放弃、过期或原投票变化使摘要失效。确认不等待他人同意, +也不能抢占执行中的操作。 + +重置仅替换模板实际提供的默认三个世界。full 替换整个世界;region 仅保留 +主世界的 `playerdata/advancements/stats`,下界与末地整世界替换。重置不是备份, +中途磁盘失败可能留下部分替换的世界,无自动回滚,应使用人工备份恢复。 + +卸载取消待投票/待确认,执行器协作收尾并恢复本次停下的服务器。 +执行中重载复用旧管理器避免双执行器,完成后再次重载才加载新实例配置。 +跨实例 `occupied_by` 仍为文件检查与写入,存在既有 TOCTOU 限制,不提供跨实例原子锁保证。 + +## English + +Existing configuration loads with safe defaults. Legacy `` and ` --confirm` +start a switch vote; `--reset/-rs` starts a reset vote. Bare `--confirm` only shows +progress. No legacy confirmation bypasses voting. `!!m` remains configurable. + +Use `switch ` (spaces supported), `reset`, `vote yes|no`, `status [id]`, +`cancel ` and `list [page]`. Switch/reset require 60%/100% of the frozen online +electorate, rounded up; timeout and per-kind cooldown are 60 seconds. The initiator +must vote explicitly. First votes are final; arrivals are excluded, departures +do not lower the threshold, and offline players cannot cast new votes. Console +can inspect votes but cannot initiate or vote. + +Load/reload/startup queries `list`. Only supported vanilla/bukkit English output +establishes a complete snapshot. Failure stays untrusted despite subsequent +join/leave events. Already queued old output is filtered by a monotonic Info.id +barrier. +Queries never overlap; expired/unloaded queries drain their old reply before a +new query starts. A server that never responds stays untrusted until a new session. +Authentication remains the Minecraft server's responsibility; offline mode allows +name impersonation. Force names match trusted player command sources. + +The configuration table above lists all new fields. `list_order` defaults to +`configured`; `activity` uses only new trusted player-time statistics. Pinned +paths precede stable sorting and pagination. Old statistics stay for audit, +never influence activity ranking, and no permanent player-name history is added. + +`backup` is visible only to users meeting `backup_permission` (default 3), and +executes immediately without voting or confirmation. It stops a running server, +copies configured worlds including player data, verifies paths/sizes/SHA-256 and +empty directories, writes a manifest, and atomically renames the temporary copy. +Missing worlds are recorded; all missing is rejected. Originally stopped servers +remain stopped. File results and verified startup results are reported separately. +Backup results are private to the requester/admin log; offline delivery is kept +only in memory. No retention deletion, restore command, plugin-data or cross-instance +backup is supplied. Reject links/reparse points and overlapping source/template/destination. + +`force_players` defaults to empty and `force_console` to false. Names do not grant +backup or management-cancel permission. Every `force switch/reset` requires personal +`force confirm ` within `force_timeout` (60 seconds). The old vote continues +until confirmation; changing/ending it invalidates the summary. Execution cannot +be preempted. Force confirmation binds owner, target, configuration and exact old vote. + +Reset replaces only worlds provided by the template. Full replaces whole worlds; +region preserves playerdata/advancements/stats only in the main world. Nether/End +are replaced entirely. Reset disk failures can leave partially replaced worlds; +there is no automatic rollback. Keep an independent backup for manual recovery. + +Unload cancels pending sessions and lets an executor finish recovery. Reload during +execution reuses that executor's manager; reload again after completion to load new +instance configuration. File-based occupied_by retains its pre-existing cross-instance +TOCTOU limitation. Merge, release and production-world operations are outside this change. diff --git a/docs/verification.md b/docs/verification.md new file mode 100644 index 0000000..5493bdc --- /dev/null +++ b/docs/verification.md @@ -0,0 +1,81 @@ +# Verification evidence / 验证证据 + +Local environment: Windows, CPython 3.11.16. Automated tests use actual MCDR +serialization, command nodes and RText, with the plugin/server-interface boundary +replaced by a deterministic test server. All filesystem changes use disposable +test directories. No production world was read or changed. + +Final local results: **43 passed on MCDR 2.7.0; 43 passed on MCDR 2.16.0**. +The isolated real-MCDR subprocess scenario passed on both versions, including +full/region reset and reload during execution. The 2.7 environment emitted its +existing pkg_resources/enum deprecation warnings; 2.16 emitted none in the unit suite. + +## Reproduce + +Minimum supported version: + +```sh +python -m venv .venv-baseline +.venv-baseline/Scripts/python -m pip install -r requirements-test-2.7.txt +.venv-baseline/Scripts/python -m pytest -q +.venv-baseline/Scripts/python tests/isolated_mcdr.py +``` + +On Linux use `.venv-baseline/bin/python`. Current-version verification uses +`requirements-test.txt` plus `mcdreforged==2.16.0` in a separate environment. +Both versions were verified locally. Existing repository packaging CI is separate +from these results; no workflow was added because the existing OAuth credential +does not have workflow scope. No extra permission was requested. + +## Coverage + +The test suite covers frozen thresholds/rounding, explicit solo vote, empty roster, +first-vote locking, duplicate/new-arrival rejection and departing players; exact-ID +expiry/cancel; legacy confirmations; concurrent votes; force summary retaining the +old vote, owner checks, exact old-vote binding, changed targets, force cancellation +and independent permissions; direct authorized backup, duplicate-click exclusion, +originally stopped server, stop timeout, startup timeout, reset failure recovery, +unload/reload while copying and fresh load without a previous module. + +File tests verify SHA-256 detects equal-size corruption, failed commit removes only +the temporary copy, manifest includes scope/skipped worlds/empty directories, +overlapping paths and real symbolic links are rejected, Windows reparse-point +attributes are rejected, missing reset-template dimensions remain untouched, and +full/region match the advertised player-data scope. Configuration invalid values, +legacy default migration, empty/bad-size pagination, stable pinned/activity order, +foreign occupied targets, failed lock acquisition and release ownership are checked. +Controlled monotonic clocks verify duplicate joins, player time, unload/remount and +absence of duplicate statistics threads. Snapshot tests cover timeout, malformed +counts, untrusted chat, event deltas and old Info.id barriers. + +`tests/isolated_mcdr.py` starts a real MCDR subprocess with the packaged plugin and +a disposable Python process producing vanilla Minecraft stdout. It exercises actual +event dispatch, trusted PlayerCommandSource, list restoration, voting, switching to +a full path with spaces, offline verified backup, both reset modes, reload during +execution, normal reload, and orderly shutdown. Its server-process input/output is +controlled; it does not run a Java Minecraft server or listen on a network port. +The foreground harness collects completion and terminates only its own subprocess +tree on failure. `isolated-mcdr.log` is generated locally and intentionally untracked. + +## Limits / 待独立验收 + +- Real Java vanilla/Paper/Bukkit output, live authentication and game-world validity + have not been verified. The deployed MCDR version is unknown; test it before rollout. +- Bukkit and localized/custom handler snapshot formats need deployment validation; + unsupported/unknown snapshots refuse voting safely. A `list` response has no nonce; + queries never overlap within one server session. Expired/unloaded queries must + drain their old response before a new query starts; a server that never responds + remains untrusted until a new service session. Info.id filters queued old output. +- Cross-instance occupied_by remains non-atomic file IO (existing TOCTOU limitation). + No distributed locking guarantee is claimed. +- Reset is not transactional; a partial disk failure can require manual world recovery. + Backup verification commits the copy independently of server recovery, so a valid + backup may coexist with failed startup. Historical backups are never auto-pruned. +- Full unload cannot synchronously join the executor from the event thread because + startup events need that thread. The old executor retains ownership, performs + recovery/finalization, and a process-wide owner prevents a fresh load starting a + second manager. In-memory backup-result delivery does not survive process exit. + +中文:自动测试与真实 MCDR 进程的可控服务端模拟通过,不等于真实 Java 游戏服务 +验收。实际部署版本、Bukkit/本地化输出及真实游戏存档须由阶段 3 独立验证。 +未执行合并、正式发布或生产存档操作。 diff --git a/lang/en_us.yml b/lang/en_us.yml index 18f2722..24a517d 100644 --- a/lang/en_us.yml +++ b/lang/en_us.yml @@ -1,4 +1,54 @@ mount: + flow: + action_switch: 'switch' + action_reset: 'reset' + action_backup: 'backup' + progress: '[Progress]' + help: 'Switch/reset start an online-player vote. Vote explicitly with the active request ID. First votes are final, and departures do not lower the threshold. Status shows progress; only the initiator or authorized managers may cancel.' + completed: 'Completed' + failed: 'Failed (error ID {error}); contact an administrator' + unknown: 'Unknown; contact an administrator' + operation_result: '{action} request {id}: {outcome}; server: {recovery}.' + cancel_force: '[Abandon force confirmation]' + force_cancelled: 'Force confirmation abandoned; the original vote is unchanged.' + yes: '[Yes]' + no: '[No]' + cancel: '[Cancel vote]' + confirm: '[Confirm force]' + switch: '[Vote switch]' + reset: '[Vote reset]' + backup: '[Backup]' + previous: '[Previous]' + next: '[Next]' + force_switch: '[Force switch]' + force_reset: '[Force reset]' + management: 'Management (force actions require personal confirmation)' + unavailable: 'Unavailable / current server' + permission: 'Permission denied.' + busy: 'An operation is pending or executing; no new operation started.' + invalid_target: 'Target unavailable, unchecked or occupied; no operation started.' + invalid_reset_path: 'Reset template or world paths are unsafe or unavailable.' + snapshot_unknown: 'Online player snapshot unavailable; no vote started.' + no_players: 'No players online; no vote started.' + not_in_roster: 'Only online players in the frozen electorate can participate.' + duplicate_vote: 'You already voted; the first vote is final.' + cooldown: 'This operation is cooling down. Try again later.' + stale: 'No matching active request; this button has expired.' + recorded: 'Vote recorded.' + vote_started: '{actor} started {action} vote: {target}. Request {id}. The initiator must vote explicitly.' + status: '{action}: {target}; initiator {actor}; request {id}. Yes {yes}/{required}, electorate {total}, No {no}, {seconds}s left. New arrivals excluded; departures do not lower threshold.' + cancelled: 'Vote {id} cancelled; no action executed.' + expired: 'Vote {id} timed out; no action executed.' + force_summary: 'Force {action}: {target}. Request {id}. Confirmation cancels exactly vote {old}; the vote continues until confirmation. Confirm within the configured timeout.' + reset_scope: 'Reset mode {mode}; worlds: {worlds}; template: {template}. Full replaces world/player data; region preserves playerdata, advancements and stats only in the main world. No chat undo.' + backup_preflight: 'Backup not started: unsafe/unavailable source or destination, or an operation is pending.' + stopping: 'Server is stopping briefly for maintenance.' + result: '{action} request {id}: {outcome}; verified backup {result}; worlds {worlds}; skipped {skipped}; server: {recovery}.' + unchanged: 'Unchanged' + started: 'Startup verified' + startup_failed: 'Startup failed or timed out; contact an administrator' + still_running: 'Original server still running; no offline operation performed' + stayed_stopped: 'Originally stopped; remains stopped' help_msg: welcome: "This MCDR server has multi MC server support by mount plugin, type §e!!mount§r to get help" click_to_fill: "Click to fill {cmd}" diff --git a/lang/zh_cn.yml b/lang/zh_cn.yml index d4c6b1a..4ddc210 100644 --- a/lang/zh_cn.yml +++ b/lang/zh_cn.yml @@ -1,4 +1,54 @@ mount: + flow: + action_switch: '切换' + action_reset: '重置' + action_backup: '备份' + progress: '[查看进度]' + help: '切换与重置发起在线玩家投票,请使用本轮请求 ID 显式投票。首票锁定,离开不降低门槛。status 查看进度,仅发起者或有管理权限者可取消。' + completed: '操作完成' + failed: '操作失败(错误编号 {error}),请联系管理员' + unknown: '状态未知,请联系管理员' + operation_result: '{action} 请求 {id}:{outcome};服务器:{recovery}。' + cancel_force: '[放弃强制确认]' + force_cancelled: '已放弃强制确认,原投票继续。' + yes: '[赞成]' + no: '[反对]' + cancel: '[取消投票]' + confirm: '[本人确认强制执行]' + switch: '[投票切换]' + reset: '[投票重置]' + backup: '[备份]' + previous: '[上一页]' + next: '[下一页]' + force_switch: '[强制切换]' + force_reset: '[强制重置]' + management: '管理区域(强制操作仍须本人确认)' + unavailable: '不可用 / 当前服' + permission: '权限不足。' + busy: '已有待处理或执行中的操作,本次未开始。' + invalid_target: '目标不可用、未检查或被占用,本次未开始。' + invalid_reset_path: '重置模板或世界路径不安全或不可用。' + snapshot_unknown: '完整在线玩家信息暂不可用,未发起投票。' + no_players: '没有在线玩家,未发起投票。' + not_in_roster: '仅本轮冻结名单中仍在线的玩家可参与。' + duplicate_vote: '本轮已投票,首票锁定,不能改票。' + cooldown: '此类操作仍在冷却,请稍后重试。' + stale: '没有匹配的活动请求,此按钮已失效。' + recorded: '已记录投票。' + vote_started: '{actor} 发起{action}投票:{target}。请求 {id}。发起者也须显式投票。' + status: '{action}:{target};发起者 {actor};请求 {id}。赞成 {yes}/{required},冻结名单 {total} 人,反对 {no},剩余 {seconds} 秒。新加入不计入,离开不降低门槛。' + cancelled: '投票 {id} 已取消,未执行操作。' + expired: '投票 {id} 已超时,未执行操作。' + force_summary: '强制{action}:{target}。请求 {id}。确认将取消准确的原投票 {old},确认前投票继续。请在配置的有效期内本人确认。' + reset_scope: '重置模式 {mode};世界:{worlds};模板:{template}。full 替换世界与玩家数据;region 仅在主世界保留 playerdata、advancements、stats。没有聊天撤销。' + backup_preflight: '备份未开始:源或目的不安全/不可用,或已有待处理操作。' + stopping: '服务器将短暂停止进行维护。' + result: '{action} 请求 {id}:{outcome};已校验备份 {result};世界 {worlds};跳过 {skipped};服务器:{recovery}。' + unchanged: '未改变' + started: '已验证启动完成' + startup_failed: '启动失败或超时,请联系管理员' + still_running: '原服务器仍在运行,未执行离线操作' + stayed_stopped: '原已停止,保持停止' help_msg: welcome: "此服务器通过mount插件添加了多挂载点支持, 输入§e!!mount§r可获取帮助信息" click_to_fill: "点击以填入{cmd}" diff --git a/mcdreforged.plugin.json b/mcdreforged.plugin.json index 7035938..5d57ffa 100644 --- a/mcdreforged.plugin.json +++ b/mcdreforged.plugin.json @@ -17,8 +17,9 @@ "archive_name": "Mount-v{version}.pyz", "resources": [ "lang", + "docs", "LICENSE", "README.md", "README_en.md" ] -} \ No newline at end of file +} diff --git a/mount/MountManager.py b/mount/MountManager.py index 07a89e2..ea17b5b 100644 --- a/mount/MountManager.py +++ b/mount/MountManager.py @@ -1,378 +1,579 @@ -import functools import math -import os.path +import os import time -from enum import Enum -from threading import Lock -from typing import Callable, List, Optional +import uuid +from dataclasses import dataclass +from threading import RLock, Thread, Timer from jproperties import Properties -from mcdreforged.api.decorator import new_thread -from mcdreforged.api.rtext import * -from mcdreforged.api.types import CommandSource +from mcdreforged.api.rtext import RAction, RText, RTextList +from mcdreforged.api.types import Info, PlayerCommandSource -from .config import MountConfig, SlotConfig -from .constants import * +from .backup_helper import preflight, run_backup +from .config import MountConfig +from .constants import COMMAND_PREFIX from .detect_helper import DetectHelper +from .list_order import order_slots from .MountSlot import MountSlot +from .players import PlayerRegistry from .reset_helper import ResetHelper -from .utils import logger, psi, rtr, debug - - -class Operation(Enum): - REQUEST_RESET = 'request reset' - REQUEST_MOUNT = 'request mount' - RESET = 'reset' - MOUNT = 'mount' - IDLE = 'idle' - - -_operation_lock = Lock() -current_op = Operation.IDLE - - -def single_op(op_type: Operation): - """ - Used to avoid operation conflict - """ - def wrapper(func: Callable): - @functools.wraps(func) - def wrap(manager, src: CommandSource, *args, **kwargs): - # allowed operation flow: - # IDLE -> REQUEST_RESET -> RESET / IDLE - # IDLE -> REQUEST_MOUNT -> MOUNT / IDLE - with _operation_lock: - global current_op - allow = current_op is Operation.IDLE \ - or (current_op is Operation.REQUEST_RESET and op_type is Operation.RESET) \ - or (current_op is Operation.REQUEST_MOUNT and op_type is Operation.MOUNT) \ - or (current_op in [Operation.REQUEST_RESET, Operation.REQUEST_MOUNT] and op_type is Operation.IDLE) - debug(f"Executing operation {op_type}, current operation {current_op}, allow = {allow}") - if allow: - func(manager, src, *args, **kwargs) - else: - src.reply(rtr('error.operation_conflict', curr=current_op.value)) - - return wrap - - return wrapper - +from .utils import logger, psi, rtr +from .vote import VoteSession -def need_restart(reason: RTextBase): - """ - Stop server, execute the function, and then restart server and reload plugin - """ - def wrapper(func: Callable): - @functools.wraps(func) - def wrap(*args, **kwargs): - debug(f"Need restart: {reason}") - global current_op - for t in range(10): - psi.broadcast(rtr('info.countdown', sec=10 - t, reason=reason)) - time.sleep(1) - psi.stop() - psi.wait_for_start() - func(*args, **kwargs) - psi.start() - current_op = Operation.IDLE - psi.refresh_changed_plugins() - return wrap - return wrapper +@dataclass +class ForceRequest: + request_id: str + kind: str + target: str + owner: str + vote_id: object + expires: float + config: dict + slot_config: dict class MountManager: - def __init__(self, config: MountConfig): - """ - constructor - :param config: the MountConfig for current mcdr server - """ - debug("Initializing MountManager...") - self.configurable_things = None + def __init__(self, config): + config.validate() self._config = config - self.current_slot: Optional[MountSlot] = MountSlot(self._config.current_server) + self.lock = RLock() + self.players = PlayerRegistry() + self.vote = self.force = self.timer = self.force_timer = None + self.executing = self.worker = None + self.closed = False + self.reloading = False + self.cooldowns = {} + self.results = {} + self.current_slot = MountSlot(config.current_server) try: - self.current_slot.lock(self._config.mount_name) + self.current_slot.lock(config.mount_name) except ResourceWarning: - logger().error("Current server is already mounted, stopping mcdr...") - psi.set_exit_after_stop_flag() - psi.stop() - return - self.next_slot: Optional[MountSlot] = None - - - def reload(self, src: CommandSource): - debug("received reload request, reloading...") - self._config = MountConfig.load() - - new_slots, removal_slots = DetectHelper.detect_slots(self._config.servers_path, self._config.available_servers) - - self._config.available_servers.extend(new_slots) - for slot in new_slots: - if not os.path.isfile(os.path.join(slot, MOUNTABLE_CONFIG)): - DetectHelper.init_conf(slot) - src.reply(rtr('detect.init_conf', path=slot)) - - for slot in removal_slots: - debug(f"removing {slot} from available servers...") - self._config.available_servers.remove(slot) - - if len(new_slots) > 0: - src.reply(rtr('detect.summary', num=len(new_slots))) - else: - src.reply(rtr('detect.summary_empty')) - self._config.save() - debug("reload path done, try to reload self plugin...") - psi.reload_plugin(psi.get_self_metadata().id) - + self.closed = True + psi.stop_exit() + raise @property def servers_as_list(self): - """ - get the list of available servers - :return: the list of available servers - """ return self._config.available_servers - @new_thread("mount-patch_properties") - def patch_properties(self, slot: MountSlot): - if self._config.overwrite_path in ['', '.', None]: - return - logger().info("Patching properties file...") - slot.load_properties() - patches = Properties() - try: - with open(self._config.overwrite_path, mode="rb") as f: - patches.load(f, 'utf-8') - except FileNotFoundError: - logger().error('File Not Found, ignore overwriting...') - return - for k, v in patches.items(): - slot.properties[k] = v - slot.save_properties() + def identity(self, src): + return src.player if src.is_player else 'console' - @new_thread("mount-patch_mcdr_config") - def patch_mcdr_config(self, slot: MountSlot): - # MCDR v2.7 provided api to modify config - logger().info("Patching mcdr config...") - current_plg_dirs = psi.get_mcdr_config()['plugin_directories'] - prev_added_plg_dir = self.current_slot.plg_dir - if prev_added_plg_dir not in ['', None, '.']: - try: - current_plg_dirs.remove(prev_added_plg_dir) - except ValueError: - pass - if slot.plg_dir not in ['', None, '.']: - try: - current_plg_dirs.append(slot.plg_dir) - except ValueError: - pass - changes = { - 'working_directory': slot.path, - 'start_command': slot._config.start_command, - 'handler': slot._config.handler, - 'plugin_directories': current_plg_dirs - } - debug(f"mcdr config changes is: {changes}") - psi.modify_mcdr_config(changes=changes) - - def request_operation(self, mode: str, source: CommandSource, path: Optional[str] = None, with_confirm=False): - pass - - @single_op(Operation.REQUEST_MOUNT) - def request_mount(self, source: CommandSource, path: str, with_confirm: bool = False): - global current_op - debug("Received mount request, evaluating...") - - if path == self.current_slot.path: - source.reply(rtr('error.is_current_mount')) - return + def can_backup(self, src): + return src.has_permission(self._config.backup_permission) - if path not in self._config.available_servers: - source.reply(rtr('error.unknown_mount_path')) - return + def can_force(self, src): + return (isinstance(src, PlayerCommandSource) and src.player in self._config.force_players if src.is_player + else src.is_console and self._config.force_console and src.has_permission(4)) - if not os.path.isdir(path): - source.reply(rtr('error.invalid_mount_path')) - return + def message(self, src, key, **kwargs): + src.reply(rtr('flow.' + key, **kwargs)) - mountable_conf_path = os.path.join(path, MOUNTABLE_CONFIG) - if not os.path.isfile(mountable_conf_path): - psi.save_config_simple(config=SlotConfig(), - file_name=mountable_conf_path, - in_data_folder=False) - source.reply(rtr('error.init_mountable_config')) - return + def button(self, key, command): + return RText(rtr('flow.' + key)).c(RAction.run_command, COMMAND_PREFIX + ' ' + command) + + def action_text(self, kind): + return rtr('flow.action_' + kind) - next_slot = MountSlot(path) - if not next_slot.checked: - source.reply(rtr('error.unchecked_path')) + def restore_players(self): + if self.closed: return + # Info.source documents console as 1 in MCDR 2.7; creating a barrier + # uses the public monotonic Info.id, discarding already queued output. + if self.players.begin(Info(1, '').id): + psi.execute('list') + + def on_start(self): + if not self.closed: + self.current_slot.on_mount() + self.restore_players() + + def on_stop(self): + self.players.invalidate() + self.current_slot.on_unmount() + + def on_info(self, info): + if not self.closed and self.players.accept(info, psi.get_mcdr_config()['handler']): + self.current_slot.set_players(self.players.snapshot()) + elif not self.closed and self.players.take_retry(): + self.restore_players() + + def player_event(self, player, joined): + if self.closed: + return + self.players.event(player, joined) + if joined: + self.current_slot.on_player_join(player) + with self.lock: + pending = self.results.pop(player, None) + if pending is not None: + psi.tell(player, pending) + else: + self.current_slot.on_player_left(player) + + def validate_target(self, kind, target): + if kind == 'switch': + if (os.path.realpath(target) == os.path.realpath(self.current_slot.path) + or target not in self.servers_as_list or not os.path.isdir(target)): + raise ValueError('invalid_target') + slot = MountSlot(target) + if not slot.checked or slot.occupied_by not in ('', None): + raise ValueError('invalid_target') + return slot + slot = MountSlot(self.current_slot.path) + if slot.occupied_by != self._config.mount_name or not slot.checked: + raise ValueError('invalid_target') + if kind == 'reset': + ResetHelper.preflight(slot.path, slot.reset_path, slot.reset_type) + elif kind == 'backup': + preflight(slot.path, slot.reset_path, + self._config.backup_root, self._config.backup_worlds, + self.servers_as_list + self._config.servers_path) + return slot + + def available(self): + if self.closed or self.executing or self.reloading: + raise ValueError('busy') + + def request_mount(self, source, path, with_confirm=False): + self.request_vote(source, 'switch', path) + + def request_reset(self, source): + self.request_vote(source, 'reset', self.current_slot.path) + + def request_vote(self, src, kind, target): + try: + target_slot = self.validate_target(kind, target) + with self.lock: + self.available() + if self.vote: + raise ValueError('busy') + roster = self.players.snapshot() + if roster is None: + raise ValueError('snapshot_unknown') + if not roster: + raise ValueError('no_players') + if not isinstance(src, PlayerCommandSource) or not src.is_player or src.player not in roster: + raise ValueError('not_in_roster') + if time.monotonic() - self.cooldowns.get(kind, -float('inf')) < self._config.vote_cooldown: + raise ValueError('cooldown') + self.vote = VoteSession(kind, target, src.player, roster, + getattr(self._config, kind + '_ratio'), self._config.vote_timeout) + vote = self.vote + self.timer = Timer(vote.timeout, self.expire_vote, [vote.request_id]) + self.timer.daemon = True + self.timer.start() + self.show_status(src, vote.request_id) + psi.broadcast(RTextList(rtr('flow.vote_started', actor=vote.owner, + action=self.action_text(kind), target=target, id=vote.request_id), ' ', + self.button('yes', 'vote '+vote.request_id+' yes'), ' ', + self.button('no', 'vote '+vote.request_id+' no'), ' ', + self.button('progress', 'status '+vote.request_id))) + if kind == 'reset': + self.reset_summary(src, target_slot) + except (ValueError, ResourceWarning) as exc: + self.message(src, str(exc) if isinstance(exc, ValueError) else 'invalid_target') + + def end_vote_locked(self): + if self.vote: + self.cooldowns[self.vote.kind] = time.monotonic() + self.vote = None + if self.timer: + self.timer.cancel() + self.timer = None + + def expire_vote(self, request_id): + with self.lock: + if not self.vote or self.vote.request_id != request_id: + return + self.end_vote_locked() + psi.broadcast(rtr('flow.expired', id=request_id)) + + def show_status(self, src, request_id=None): + with self.lock: + vote = self.vote + if not vote or request_id and vote.request_id != request_id: + self.message(src, 'stale') + return + text = RTextList(rtr('flow.status', id=vote.request_id, action=self.action_text(vote.kind), target=vote.target, + actor=vote.owner, yes=vote.yes, required=vote.threshold, total=len(vote.roster), + no=len(vote.votes)-vote.yes, seconds=max(0, math.ceil(vote.created+vote.timeout-time.monotonic()))), + ' ', self.button('yes', 'vote ' + vote.request_id + ' yes'), + ' ', self.button('no', 'vote ' + vote.request_id + ' no')) + if self.identity(src) == vote.owner or src.has_permission(self._config.cancel_permission): + text.append(' ', self.button('cancel', 'cancel ' + vote.request_id)) + src.reply(text) + + def cast_vote(self, src, request_id, choice): + try: + with self.lock: + self.available() + vote = self.vote + if not vote or vote.request_id != request_id or time.monotonic() >= vote.created+vote.timeout: + raise ValueError('stale') + if not isinstance(src, PlayerCommandSource) or not src.is_player or src.player not in (self.players.snapshot() or ()): + raise ValueError('not_in_roster') + passed = vote.cast(src.player, choice) + if passed: + self.executing = vote.request_id + self.end_vote_locked() + self.message(src, 'recorded') + if passed: + self.launch(vote.request_id, vote.kind, vote.target, src) + else: + self.show_status(src, request_id) + except ValueError as exc: + self.message(src, str(exc)) + + def cancel(self, src, request_id=None): + with self.lock: + if request_id and self.force and self.force.request_id == request_id: + if self.identity(src) != self.force.owner and not src.has_permission(self._config.cancel_permission): + self.message(src, 'permission') + return + self.force = None + if self.force_timer: + self.force_timer.cancel() + self.message(src, 'force_cancelled') + return + vote = self.vote + if not vote or request_id and vote.request_id != request_id: + self.message(src, 'stale') + return + if self.identity(src) != vote.owner and not src.has_permission(self._config.cancel_permission): + self.message(src, 'permission') + return + self.end_vote_locked() + psi.broadcast(rtr('flow.cancelled', id=vote.request_id)) + + abort_operation = cancel + confirm_operation = show_status + + def reset_summary(self, src, slot): + _, template, present = ResetHelper.preflight(slot.path, slot.reset_path, slot.reset_type) + self.message(src, 'reset_scope', mode=slot.reset_type, worlds=', '.join(present), template=template.name) + + @staticmethod + def slot_contract(slot): + return {key: value for key, value in slot.get_config().serialize().items() if key != 'stats'} + def request_force(self, src, kind, target=None): + if not self.can_force(src): + self.message(src, 'permission') + return + target = target or self.current_slot.path try: - next_slot.lock(self._config.mount_name) - self.next_slot = next_slot - except ResourceWarning: - source.reply(rtr("error.occupied")) - self.next_slot.release(self._config.mount_name) - self.next_slot = None + target_slot = self.validate_target(kind, target) + with self.lock: + self.available() + request_id = uuid.uuid4().hex + self.force = ForceRequest(request_id, kind, target, self.identity(src), + self.vote.request_id if self.vote else None, + time.monotonic()+self._config.force_timeout, self._config.serialize(), + self.slot_contract(target_slot)) + if self.force_timer: + self.force_timer.cancel() + self.force_timer = Timer(self._config.force_timeout, self.expire_force, [request_id]) + self.force_timer.daemon = True + self.force_timer.start() + old = self.force.vote_id or '-' + src.reply(RTextList(rtr('flow.force_summary', action=self.action_text(kind), target=target, id=request_id, old=old), + ' ', self.button('confirm', 'force confirm ' + request_id), + ' ', self.button('cancel_force', 'cancel ' + request_id))) + if kind == 'reset': + self.reset_summary(src, target_slot) + except (ValueError, ResourceWarning): + self.message(src, 'invalid_target') + + def expire_force(self, request_id): + with self.lock: + if self.force and self.force.request_id == request_id: + self.force = None + + def confirm_force(self, src, request_id): + with self.lock: + candidate = self.force + if not candidate or candidate.request_id != request_id or not self.can_force(src): + self.message(src, 'stale') return - - debug("Mount request accepted, waiting for confirmation...") - current_op = Operation.REQUEST_MOUNT - text = RTextList( - RText(rtr("info.mount_request", server_path=self.next_slot.path), color=RColor.yellow), - RText(rtr('info.confirm'), color=RColor.green) - .c(RAction.suggest_command, f'{COMMAND_PREFIX} --confirm'), - ' ', - RText(rtr('info.abort'), color=RColor.red).c(RAction.suggest_command, f'{COMMAND_PREFIX} --abort') - ) - source.reply(text) - - @single_op(Operation.REQUEST_RESET) - def request_reset(self, source: CommandSource): - debug("Received reset request, evaluating...") - global current_op - # check for operation here - if self.current_slot.reset_path in ['', None, '.'] \ - or not os.path.isdir( - os.path.join(self.current_slot.path, self.current_slot.reset_path)): - source.reply(rtr('error.reset.invalid_path')) + try: + target_slot = self.validate_target(candidate.kind, candidate.target) + if self.slot_contract(target_slot) != candidate.slot_config: + raise ValueError('invalid_target') + except (ValueError, ResourceWarning): + self.message(src, 'invalid_target') return - if self.current_slot.reset_type not in ['full', 'region']: - source.reply(rtr('error.reset.invalid_type')) + with self.lock: + force = self.force + if (self.closed or self.executing or not force or force.request_id != request_id + or force.owner != self.identity(src) or not self.can_force(src) + or time.monotonic() >= force.expires or force.config != self._config.serialize() + or force.vote_id != (self.vote.request_id if self.vote else None)): + self.message(src, 'stale') + return + self.executing = request_id + self.force = None + if self.force_timer: + self.force_timer.cancel() + old = self.vote.request_id if self.vote else None + self.end_vote_locked() + if old: + psi.broadcast(rtr('flow.cancelled', id=old)) + self.launch(request_id, force.kind, force.target, src) + + def request_backup(self, src): + if not self.can_backup(src): + self.message(src, 'permission') return - debug("Reset request accepted, waiting for confirmation...") - current_op = Operation.REQUEST_RESET - text = RTextList( - RText(rtr("info.reset_request", reset_path=self.current_slot.reset_path), color=RColor.yellow), - RText(rtr('info.confirm'), color=RColor.green) - .c(RAction.suggest_command, f'{COMMAND_PREFIX} --confirm'), - ' ', - RText(rtr('info.abort'), color=RColor.red).c(RAction.suggest_command, f'{COMMAND_PREFIX} --abort') - ) - source.reply(text) - - def confirm_operation(self, source: CommandSource): - debug("Received confirm request, evaluating...") - if current_op is Operation.REQUEST_RESET: - self._do_reset(source, self.current_slot) - elif current_op is Operation.REQUEST_MOUNT: - if not isinstance(self.next_slot, MountSlot): - source.reply(rtr('error.nothing_to_confirm')) + try: + self.validate_target('backup', self.current_slot.path) + with self.lock: + self.available() + if self.vote: + raise ValueError('busy') + request_id = uuid.uuid4().hex + self.executing = request_id + self.launch(request_id, 'backup', self.current_slot.path, src) + except (ValueError, ResourceWarning): + self.message(src, 'backup_preflight') + + def launch(self, request_id, kind, target, src): + with self.lock: + # Even an unload between claim and launch needs the finalizer. + # The worker sees closed before stopping and releases ownership. + self.worker = Thread(target=self.execute, args=(request_id, kind, target, src), name='mount-executor') + self.worker.start() + + def wait_state(self, predicate, timeout): + deadline = time.monotonic()+timeout + while not predicate(): + if time.monotonic() >= deadline: + raise TimeoutError('server state timeout') + time.sleep(0.05) + + def execute(self, request_id, kind, target, src): + slot, acquired, stopped = None, False, False + was_running, stop_requested = None, False + result, error, recovery = None, None, 'unchanged' + try: + was_running = psi.is_server_running() + slot = self.validate_target(kind, target) + if kind == 'switch': + slot.lock(self._config.mount_name) + acquired = True + if self.closed: + raise RuntimeError('unloading') + if was_running: + psi.broadcast(rtr('flow.stopping')) + stop_requested = True + psi.stop() + self.wait_state(lambda: not psi.is_server_running(), self._config.stop_timeout) + stopped = True + self.current_slot.on_unmount() + self.players.invalidate() + if kind == 'backup': + slot = self.validate_target(kind, target) + result = run_backup(slot.path, slot.reset_path, self._config.backup_root, + self._config.backup_worlds, self.servers_as_list+self._config.servers_path) + elif kind == 'reset': + slot = self.validate_target(kind, target) + ResetHelper.reset(slot.path, slot.reset_path, slot.reset_type) else: - self._do_mount(source, self.next_slot) - - @new_thread('mount-resetting') - @single_op(Operation.RESET) - @need_restart(reason=rtr('info.countdown_reason.reset')) - def _do_reset(self, source: CommandSource, slot: MountSlot): - debug(f"Resetting current slot {slot.path}...") - global current_op - current_op = Operation.RESET - ResetHelper.reset(slot.path, slot._config.reset_path, slot._config.reset_type) - - @new_thread("mount-mounting") - @single_op(Operation.MOUNT) - @need_restart(reason=rtr('info.countdown_reason.mount')) - def _do_mount(self, source: CommandSource, slot: MountSlot): - debug(f"Mounting slot {slot.path}...") - global current_op - # do the mount - current_op = Operation.MOUNT - self.patch_properties(slot) - self.patch_mcdr_config(slot) - self.current_slot.release(self._config.mount_name) - self.current_slot, self.next_slot = slot, None - self._config.current_server = slot.path - self._config.save() - - @single_op(Operation.IDLE) - def abort_operation(self, source: CommandSource): - debug("Received abort request, evaluating...") - global current_op - current_op = Operation.IDLE - if not isinstance(self.next_slot, MountSlot): - source.reply(rtr("error.nothing_to_abort")) + slot.load_config() + if not slot.checked or slot.occupied_by != self._config.mount_name: + raise ValueError('invalid_target') + self.patch_properties(slot) + old_config, old_path, previous = psi.get_mcdr_config(), self._config.current_server, self.current_slot + try: + self.patch_mcdr_config(slot) + self._config.current_server = slot.path + self._config.save() + except Exception: + self._config.current_server = old_path + psi.modify_mcdr_config(changes={k: old_config[k] for k in ( + 'working_directory', 'start_command', 'handler', 'plugin_directories')}) + raise + self.current_slot = slot + acquired = False + previous.release(self._config.mount_name) + except Exception: + error = uuid.uuid4().hex + logger().exception('Mount operation %s failed (%s)', request_id, error) + finally: + try: + if was_running and stop_requested and (stopped or not psi.is_server_running()): + psi.start() + self.wait_state(psi.is_server_startup, self._config.start_timeout) + recovery = 'started' + if not self.closed: + self.current_slot.on_mount() + if self.players.snapshot() is None: + self.restore_players() + elif was_running: + recovery = 'still_running' if psi.is_server_running() else 'unknown' + else: + recovery = 'stayed_stopped' if was_running is False else 'unknown' + except Exception: + recovery = 'startup_failed' + logger().exception('Mount server recovery failed') + try: + if acquired: + slot.release(self._config.mount_name) + if self.closed: + self.current_slot.release(self._config.mount_name) + except Exception: + error = error or uuid.uuid4().hex + logger().exception('Mount lock cleanup failed') + try: + text = rtr('flow.result' if kind == 'backup' else 'flow.operation_result', + action=self.action_text(kind), id=request_id, result=(result[0] if result else '-'), + worlds=(', '.join(result[1]) if result else '-'), + skipped=(', '.join(result[2]) if result else '-'), + outcome=rtr('flow.failed', error=error) if error else rtr('flow.completed'), + error=error or '-', recovery=rtr('flow.'+recovery)) + logger().info(str(text)) + if kind == 'backup': + if src.is_player: + with self.lock: + self.results[src.player] = text + if src.player in (self.players.snapshot() or ()): + with self.lock: + pending = self.results.pop(src.player, None) + if pending is not None: + psi.tell(src.player, pending) + else: + src.reply(text) + else: + psi.broadcast(text) + finally: + with self.lock: + if self.executing == request_id: + self.executing = None + + def patch_properties(self, slot): + if self._config.overwrite_path in ('', '.', None): return - if current_op is Operation.REQUEST_MOUNT: - self.next_slot.release(self._config.mount_name) - self.next_slot = None - - @new_thread('mount-list') - def list_servers(self, src: CommandSource, page: int = 1): - debug(f"Received list request for page {page}") - list_size = self._config.list_size - available_servers = self._config.available_servers - max_page = math.ceil(len(available_servers) / list_size) - if not 1 <= page <= max_page: - page = 1 - - left = (page - 1) * list_size - right = min(len(available_servers), page * list_size) - src.reply(RText(rtr('list.title'))) - for server in available_servers[left: right]: - slot = MountSlot(path=server) - src.reply( - slot - .get_config() - .as_list_entry(slot.name, slot.path, - self._config.mount_name, self._config.current_server - )) - - # <<< curr/total >>> - link_color = { - True: RColor.green, - False: RColor.gray - } - - left_link: RText - if page <= 1: - left_link = RText('<<<', color=link_color[False]).h(rtr('list.no_more_page')) - else: - left_link = RText('<<<', color=link_color[True]).h(rtr('list.prev_page')) \ - .c(RAction.suggest_command, COMMAND_PREFIX + ' --list ' + str(page - 1)) + slot.load_properties() + patches = Properties() + try: + with open(self._config.overwrite_path, 'rb') as stream: + patches.load(stream, 'utf-8') + except FileNotFoundError: + return + for key, value in patches.items(): + slot.properties[key] = value + slot.save_properties() - right_link: RText - if page >= max_page: - right_link = RText('>>>', color=link_color[False]).h(rtr('list.no_more_page')) - else: - right_link = RText('>>>', color=link_color[True]).h(rtr('list.next_page')) \ - .c(RAction.suggest_command, COMMAND_PREFIX + ' --list ' + str(page + 1)) - - footer = RTextList( - left_link, - f' {page} / {max_page} ', - right_link - ) - if left == right: + def patch_mcdr_config(self, slot): + directories = list(psi.get_mcdr_config()['plugin_directories']) + if self.current_slot.plg_dir in directories: + directories.remove(self.current_slot.plg_dir) + if slot.plg_dir and slot.plg_dir not in directories: + directories.append(slot.plg_dir) + psi.modify_mcdr_config(changes={'working_directory': slot.path, 'start_command': slot.start_command, + 'handler': slot.handler, 'plugin_directories': directories}) + + def close(self): + with self.lock: + old = self.vote.request_id if self.vote else None + self.closed = True + self.end_vote_locked() + self.force = None + if self.force_timer: + self.force_timer.cancel() + self.current_slot.on_unmount() + self.players.invalidate(preserve_query=True) + if not self.executing: + self.current_slot.release(self._config.mount_name) + if old: + psi.broadcast(rtr('flow.cancelled', id=old)) + + def list_servers(self, src, page=1): + views, slots = [], {} + for path in self.servers_as_list: + try: + if not os.path.isdir(path): + raise ValueError('missing path') + slot = MountSlot(path) + slots[path] = slot + score = slot.stats.player_time_ns_v2 if slot.stats.schema_version == 2 else 0 + views.append((path, max(0, score))) + except Exception: + views.append((path, 0)) + views = order_slots(views, self._config.pinned_servers, self._config.list_order) + size = self._config.list_size + if not isinstance(size, int) or size <= 0: + size = 15 + pages = max(1, math.ceil(len(views)/size)) + page = max(1, min(pages, int(page))) + src.reply(rtr('list.title')) + for path, _ in views[(page-1)*size:page*size]: + slot, current = slots.get(path), path == self.current_slot.path + row = RTextList(path, ' ') + if slot: + row.append(slot.desc, ' ') + if current and slot.reset_path: + row.append(self.button('reset', 'reset')) + elif not current and slot.checked and not slot.occupied_by: + row.append(self.button('switch', 'switch '+path)) + else: + row.append(rtr('flow.unavailable')) + else: + row.append(rtr('flow.unavailable')) + if current and self.can_backup(src): + row.append(' ', self.button('backup', 'backup')) + src.reply(row) + if not views: src.reply(rtr('list.empty')) - else: - src.reply(footer) - - - def get_config(self, config_key, src: Optional[CommandSource] = None): - if src is not None: - src.reply(self._config.__getattribute__(config_key)) - else: - return self._config.__getattribute__(config_key) - - def set_config(self, src: CommandSource, config_key, config_value): - debug(f"Setting config [{config_key}] to [{config_value}]") - assert hasattr(self._config, config_key) - self._config.__setattr__(name=config_key, value=config_value) - self._config.save() - src.reply(rtr("config.set_value", config_key, config_value)) + src.reply(RTextList(self.button('previous', 'list '+str(max(1,page-1))), + ' {}/{} '.format(page,pages), self.button('next', 'list '+str(min(pages,page+1))))) + if self.can_force(src): + src.reply(RTextList(rtr('flow.management'), ' ', self.button('force_reset', 'force reset'))) + for path, _ in views: + if path != self.current_slot.path: + src.reply(self.button('force_switch', 'force switch '+path)) + + def get_config(self, key, src=None): + value = getattr(self._config, key) + if src: + src.reply(str(value)) + return value @staticmethod - def list_path_config(src: CommandSource, path: str): - slot_instance = MountSlot(path) - src.reply(slot_instance.get_config().display(path)) - del slot_instance - - def edit_path_config(self, src, path: CommandSource, key: str, value): - debug(f"Editing path({path}) config [{key}] to [{value}]") - slot_instance = MountSlot(path) - src.reply(slot_instance.edit_config(key, value)) - self.current_slot.load_config() - del slot_instance + def list_path_config(src, path): + src.reply(MountSlot(path).get_config().display(path)) + + def edit_path_config(self, src, path, key, value): + with self.lock: + if self.executing or self.closed: + self.message(src, 'busy') + return + self.executing = request_id = uuid.uuid4().hex + try: + slot = self.current_slot if path == self.current_slot.path else MountSlot(path) + src.reply(slot.edit_config(key, value)) + finally: + with self.lock: + if self.executing == request_id: + self.executing = None + + def reload(self, src): + with self.lock: + if self.executing or self.closed or self.reloading: + self.message(src, 'busy') + return + self.reloading = True + try: + config = MountConfig.load() + added, removed = DetectHelper.detect_slots(config.servers_path, config.available_servers) + config.available_servers.extend(added) + for path in removed: + config.available_servers.remove(path) + for path in added: + DetectHelper.init_conf(path) + config.save() + psi.reload_plugin(psi.get_self_metadata().id) + finally: + with self.lock: + self.reloading = False diff --git a/mount/MountSlot.py b/mount/MountSlot.py index dfbf243..61c296a 100644 --- a/mount/MountSlot.py +++ b/mount/MountSlot.py @@ -1,7 +1,6 @@ import os import time -from asyncio import Event -from threading import Lock, Thread +from threading import Event, Lock, RLock, Thread, current_thread from typing import Callable from jproperties import Properties @@ -14,21 +13,15 @@ class StatsChecker(Thread): def __init__(self, interval: int, cb: Callable[[], None]): super().__init__() - self.setDaemon(True) - self.setName(self.__class__.__name__) + self.daemon = True + self.name = self.__class__.__name__ self._report_time = time.time() self.stop_event = Event() self.interval = interval self._callback = cb def run(self): - while True: # loop until stop - while True: # # loop for report - if self.stop_event.wait(1): - return - if time.time() - self._report_time > self.interval: - break - self._report_time = time.time() + while not self.stop_event.wait(self.interval): self._callback() def stop(self): @@ -37,13 +30,14 @@ def stop(self): class MountSlot: def __init__(self, path: str): self.path = path + self.__stats_lock = RLock() self.load_config() self.properties = Properties() self.slot_lock = Lock() - self.__players = [] + self.__players = set() self.__players_lock = Lock() - self.__stats_lock = Lock() - self.__stats_checker = StatsChecker(60, self.update_stats) + self.__stats_checker = None + self.__anchor = None @property def name(self) -> str: @@ -58,11 +52,12 @@ def plg_dir(self): def load_config(self): debug(f'Loading slot config in {self.path}...') - self._config = psi.load_config_simple( - target_class=Config, - file_name=os.path.join(self.path, MOUNTABLE_CONFIG), - in_data_folder=False - ) + with self.__stats_lock: + self._config = psi.load_config_simple( + target_class=Config, + file_name=os.path.join(self.path, MOUNTABLE_CONFIG), + in_data_folder=False + ) def save_config(self): debug(f'Saving slot config in {self.path}...') @@ -97,24 +92,33 @@ def lock(self, mount_name: str): debug(f'Locking {self.path}...') acquired = self.slot_lock.acquire(blocking=False) if acquired: - self.load_config() - if self._config.occupied_by in ["", None, mount_name]: - self._config.occupied_by = mount_name - self.save_config() - return + try: + self.load_config() + if self._config.occupied_by in ["", None, mount_name]: + self._config.occupied_by = mount_name + self.save_config() + return + except BaseException: + self.slot_lock.release() + raise + self.slot_lock.release() raise ResourceWarning def release(self, mount_name: str): debug(f'Releasing slot {self.path}...') - self.load_config() - if self._config.occupied_by == mount_name: - self._config.occupied_by = "" - self.save_config() - self.slot_lock.release() + if not self.slot_lock.locked(): + return + try: + self.load_config() + if self._config.occupied_by == mount_name: + self._config.occupied_by = "" + self.save_config() + finally: + self.slot_lock.release() def edit_config(self, key: str, value: str): debug(f'Editing slot config in {self.path}, [{key}]] set to [{value}]') - if key in [ 'stats' ]: + if key in ['stats', 'occupied_by']: return rtr('config.cannot_edit', key=rtr(f'config.slot.{key}')) if isinstance(self._config.__getattribute__(key), bool): value = value.lower() @@ -129,52 +133,53 @@ def edit_config(self, key: str, value: str): return rtr('config.set_value', key=rtr(f'config.slot.{key}'), value=self._config.__getattribute__(key)) def on_player_join(self, player: str): - debug(f'Player {player} joined slot {self.path}, saving stats...') - with self.__players_lock: - self.update_stats() - self._config.stats.total_players = self._config.stats.total_players + 1 - self.__players.append(player) + with self.__stats_lock: self.update_stats() + self.__players.add(player) def on_player_left(self, player: str): - debug(f'Player {player} left slot {self.path}, saving stats...') - try: - with self.__players_lock: - self.update_stats() - self.__players.remove(player) - except ValueError: - pass + with self.__stats_lock: + self.update_stats() + self.__players.discard(player) + + def set_players(self, players): + with self.__stats_lock: + self.update_stats() + self.__players = set(players) def on_mount(self): - debug(f'slot {self.path} is mounted, saving stats...') - if not self.__stats_checker.is_alive: - with self.__stats_lock: - current = time.time_ns - self._config.stats.last_mount_ns = current - self.save_config() - debug(f'starting stats checker for slot {self.path}...') + with self.__stats_lock: + if self.__anchor is not None: + return + self.__anchor = time.monotonic_ns() + self._config.stats.last_mount_ns = time.time_ns() + self.__stats_checker = StatsChecker(60, self.update_stats) self.__stats_checker.start() def on_unmount(self): - debug(f'slot {self.path} is unmounted, saving stats...') - if self.__stats_checker.is_alive: + with self.__stats_lock: self.update_stats() - self.__stats_checker.stop() + self.__anchor = None + self.__players.clear() + checker = self.__stats_checker + if checker: + checker.stop() + if checker and checker is not current_thread(): + checker.join() def update_stats(self): - debug(f'Updating stats in slot {self.path}...') - if not self.__stats_checker.is_alive: - return with self.__stats_lock: - current = time.time_ns() - prev = self._config.stats.last_mount_ns - p = len(self.__players) - t = current - prev + if self.__anchor is None: + return + current = time.monotonic_ns() + t = max(0, current - self.__anchor) + self.__anchor = current stats = self._config.stats - stats.last_mount_ns = current - stats.total_use_time = stats.total_use_time + t - stats.total_player_time = stats.total_player_time + t * p + stats.use_time_ns_v2 += t + stats.player_time_ns_v2 += t * len(self.__players) + # Statistics must not overwrite a freshly edited reset template, + # permissions/configuration or another instance's occupation. + self.load_config() self._config.stats = stats - debug(f'current stats: {stats}') self.save_config() diff --git a/mount/backup_helper.py b/mount/backup_helper.py new file mode 100644 index 0000000..d4f857d --- /dev/null +++ b/mount/backup_helper.py @@ -0,0 +1,101 @@ +"""Offline, verified filesystem snapshots. No server lifecycle operations.""" +import hashlib +import json +import os +import shutil +import uuid +from pathlib import Path + + +def reject_links(path): + path = Path(path).absolute() + for parent in [path, *path.parents]: + if parent.exists() and (parent.is_symlink() or + getattr(parent.lstat(), 'st_file_attributes', 0) & 0x400): + raise ValueError('unsafe_link') + if path.is_dir(): + for root, dirs, files in os.walk(path, followlinks=False): + for name in dirs + files: + p = Path(root, name) + if p.is_symlink() or getattr(p.lstat(), 'st_file_attributes', 0) & 0x400: + raise ValueError('unsafe_link') + + +def overlaps(a, b): + a, b = Path(a).resolve(), Path(b).resolve() + return a == b or a in b.parents or b in a.parents + + +def preflight(slot_path, reset_path, root, worlds, all_slots=()): + slot, dest = Path(slot_path).resolve(), Path(root).resolve() + reject_links(root) + protected = [slot, *(Path(p).resolve() for p in all_slots)] + if reset_path: + protected.append((slot / reset_path).resolve()) + if any(overlaps(dest, p) for p in protected): + raise ValueError('invalid_backup_path') + sources, missing = [], [] + for name in worlds: + p = slot / name + if not name or Path(name).is_absolute() or '..' in Path(name).parts or p.resolve() == slot: + raise ValueError('invalid_world_path') + reject_links(p) + if p.is_dir(): + sources.append((name, p)) + else: + missing.append(name) + if not sources: + raise ValueError('missing_worlds') + if any(overlaps(a[1], b[1]) for i, a in enumerate(sources) for b in sources[i+1:]): + raise ValueError('overlapping_worlds') + if reset_path and any(overlaps(p, slot / reset_path) for _, p in sources): + raise ValueError('invalid_reset_path') + ancestor = dest + while not ancestor.exists(): + ancestor = ancestor.parent + required = sum(p.stat().st_size for _, src in sources for p in src.rglob('*') if p.is_file()) + if shutil.disk_usage(ancestor).free < required * 1.1: + raise ValueError('insufficient_space') + return dest, sources, missing + + +def inventory(root): + reject_links(root) + result = {} + for p in sorted(Path(root).rglob('*')): + relative = p.relative_to(root).as_posix() + if p.is_dir(): + result[relative + '/'] = None + elif p.is_file(): + digest = hashlib.sha256() + with p.open('rb') as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b''): + digest.update(chunk) + result[relative] = [p.stat().st_size, digest.hexdigest()] + else: + raise ValueError('unsafe_file') + return result + + +def run_backup(slot_path, reset_path, root, worlds, all_slots=()): + dest, sources, missing = preflight(slot_path, reset_path, root, worlds, all_slots) + dest.mkdir(parents=True, exist_ok=True) + backup_id = uuid.uuid4().hex + temporary, final = dest / (backup_id + '.tmp'), dest / backup_id + temporary.mkdir() + try: + manifests = {} + for name, src in sources: + before = inventory(src) + shutil.copytree(src, temporary / name) + if before != inventory(temporary / name) or before != inventory(src): + raise ValueError('validation_failed') + manifests[name] = before + (temporary / 'manifest.json').write_text(json.dumps({ + 'id': backup_id, 'worlds': manifests, 'skipped': missing, + 'algorithm': 'sha256'}, ensure_ascii=False, indent=2), encoding='utf-8') + temporary.rename(final) + return backup_id, [name for name, _ in sources], missing + finally: + if temporary.exists(): + shutil.rmtree(temporary) diff --git a/mount/cmd_tree.py b/mount/cmd_tree.py index 92cf145..ef76b89 100644 --- a/mount/cmd_tree.py +++ b/mount/cmd_tree.py @@ -1,96 +1,66 @@ -from mcdreforged.api.command import GreedyText, Literal, Text, Number -from mcdreforged.api.rtext import RAction, RColor, RText, RTextList -from mcdreforged.api.types import CommandSource, PluginServerInterface +from mcdreforged.api.command import GreedyText, Literal, Text, Integer +from mcdreforged.api.rtext import RAction, RText, RTextList from .config import SlotConfig from .constants import COMMAND_PREFIX -from .MountManager import MountManager -from .utils import rtr, psi +from .utils import rtr -def get_clickable(cmd: str): - cmd = f'{COMMAND_PREFIX} {cmd}' - return RText(cmd, color=RColor.yellow).h(rtr('help_msg.click_to_fill', cmd=cmd)).c(RAction.suggest_command, cmd) +def register_commands(server, manager): + def help_text(src): + commands = ['switch ', 'reset', 'list [page]', 'vote yes|no', + 'status [id]', 'cancel '] + if manager.can_backup(src): + commands.append('backup') + if manager.can_force(src): + commands.extend(['force switch ', 'force reset', 'force confirm ']) + if src.has_permission(3): + commands.extend(['--config set ', '--reload']) + src.reply(RTextList(rtr('help_msg.title', version=server.get_self_metadata().version), '\n', + rtr('flow.help'), '\n', '\n'.join(COMMAND_PREFIX+' '+cmd for cmd in commands))) + def target(kind, force=False): + return GreedyText('target').runs(lambda src, ctx: + manager.request_force(src, kind, ctx['target']) if force else manager.request_mount(src, ctx['target'])) -def get_help(src: CommandSource): - sub_command = ['reset', 'list', 'reload', 'config'] - payload = RTextList(RText(rtr('help_msg.title', version=psi.get_self_metadata().version)), '\n') - payload.append( - get_clickable(''), - ' ', - rtr('help_msg.command.mount'), - '\n' - ) - for i in sub_command: - payload.append( - get_clickable('--' + i), - ' ', - rtr(f'help_msg.command.{i}'), - '\n' - ) - src.reply(payload) + def legacy(src, ctx): + path = ctx['legacy'] + if path.endswith(' --confirm'): + path = path[:-10] + manager.request_mount(src, path) - -def get_config_help(src: CommandSource): - payload = RTextList( - get_clickable(" --config "), - ' ', - RText(rtr('help_msg.config.all')), - '\n', - get_clickable(" --config set "), - ' ', - RText(rtr('help_msg.config.edit')) - ) - src.reply(payload) - - -def register_commands(server: PluginServerInterface, manager: MountManager): - root_prefix = {COMMAND_PREFIX, "!!m"} if manager.get_config( - 'short_prefix') else COMMAND_PREFIX - - def get_slot_node(): # to check if is a usable slot - return Text('slot_path').requires(lambda src, ctx: ctx['slot_path'] in manager.servers_as_list, - lambda src, ctx: rtr('error.invalid_mount_path')) - - config_node = Literal({"--config", "-cfg"}).runs(lambda src: get_config_help(src))\ - .requires(lambda src: src.has_permission(3), lambda src: src.reply(rtr('error.perm_deny'))).then( - get_slot_node().runs( - lambda src, ctx: manager.list_path_config(src, ctx['slot_path'])) - .then(Literal('set').then(Text('key').requires( - lambda src, ctx: ctx['key'] in SlotConfig.get_field_annotations( - ), - lambda src, ctx: rtr('config.invalid_key', key=ctx['key'])) - .then(GreedyText('value').runs( - lambda src, ctx: manager.edit_path_config( - src, ctx['slot_path'], ctx['key'], ctx['value']) - ))))) - main_node = Literal(root_prefix).runs( - lambda src: get_help(src) - ).then( - Literal({'--reset', '-rs'}).runs(lambda src: manager.request_reset(src)) - ).then( - Literal('--confirm').runs(lambda src, ctx: manager.confirm_operation(src)) - ).then( - Literal({'--list', '-l'}).runs( - lambda src, ctx: manager.list_servers(src) - ).then( - Number('page').runs(lambda src, ctx: manager.list_servers(src, ctx['page'])) - ) - ).then( - Literal('--abort').runs(lambda src, ctx: manager.abort_operation(src)) - ).then( - config_node - ).then( - Literal({'--reload', '-r'}).runs(lambda src, ctx: manager.reload(src)) - ).then( - get_slot_node().runs( - lambda src, ctx: manager.request_mount( - src, ctx['slot_path'], with_confirm=False) - ).then( - Literal("--confirm") - .runs(lambda src, ctx: manager.request_mount(src, ctx['slot_path'], with_confirm=True)) - ) - ) - server.register_command(main_node) - server.register_help_message(COMMAND_PREFIX, rtr("help_msg.brief")) + root = Literal({COMMAND_PREFIX, '!!m'} if manager.get_config('short_prefix') else COMMAND_PREFIX).runs(help_text) + root.then(Literal('switch').then(target('switch'))) + root.then(Literal({'reset', '--reset', '-rs'}).runs(manager.request_reset)) + root.then(Literal({'list', '--list', '-l'}).runs(lambda src: manager.list_servers(src)).then( + Integer('page').runs(lambda src, ctx: manager.list_servers(src, ctx['page'])))) + root.then(Literal({'status', '--confirm'}).runs(lambda src: manager.show_status(src)).then( + Text('id').runs(lambda src, ctx: manager.show_status(src, ctx['id'])))) + root.then(Literal('vote').then(Text('id').then( + Literal('yes').runs(lambda src, ctx: manager.cast_vote(src, ctx['id'], True))).then( + Literal('no').runs(lambda src, ctx: manager.cast_vote(src, ctx['id'], False))))) + root.then(Literal('cancel').then(Text('id').runs(lambda src, ctx: manager.cancel(src, ctx['id'])))) + root.then(Literal('--abort').runs(lambda src: manager.cancel(src))) + root.then(Literal('backup').requires(manager.can_backup).runs(manager.request_backup)) + force = Literal('force').requires(manager.can_force) + force.then(Literal('switch').then(target('switch', True))) + force.then(Literal('reset').runs(lambda src: manager.request_force(src, 'reset'))) + force.then(Literal('confirm').then(Text('id').runs(lambda src, ctx: manager.confirm_force(src, ctx['id'])))) + root.then(force) + root.then(Literal({'--reload', '-r'}).requires(lambda src: src.has_permission(3)).runs(manager.reload)) + # Greedy config arguments allow spaces in an exact configured path. + def config_command(src, ctx): + value = ctx['config'] + if ' set ' not in value: + if value in manager.servers_as_list: + manager.list_path_config(src, value) + return + path, arguments = value.rsplit(' set ', 1) + key, separator, val = arguments.partition(' ') + if path in manager.servers_as_list and separator and key in SlotConfig.get_field_annotations(): + manager.edit_path_config(src, path, key, val) + root.then(Literal({'--config', '-cfg'}).requires(lambda src: src.has_permission(3)).then( + GreedyText('config').runs(config_command))) + root.then(GreedyText('legacy').runs(legacy)) + server.register_command(root) + server.register_help_message(COMMAND_PREFIX, rtr('help_msg.brief')) diff --git a/mount/config.py b/mount/config.py index 239b4eb..3b78be7 100644 --- a/mount/config.py +++ b/mount/config.py @@ -9,7 +9,7 @@ class MountConfig(Serializable): welcome_player: bool = True - short_prefix = True # let !!m to be a short command + short_prefix: bool = True # let !!m to be a short command servers_path: Union[str, List[str]] = [ "../servers" ] overwrite_path: str = "../servers/server.properties.overwrite" @@ -21,6 +21,37 @@ class MountConfig(Serializable): mount_name: str = "MountDemo" list_size: int = 15 debug: bool = False + pinned_servers: List[str] = [] + list_order: str = 'configured' + switch_ratio: float = 0.6 + reset_ratio: float = 1.0 + vote_timeout: int = 60 + vote_cooldown: int = 60 + force_timeout: int = 60 + backup_permission: int = 3 + cancel_permission: int = 3 + force_players: List[str] = [] + force_console: bool = False + backup_root: str = '../mount-backups' + backup_worlds: List[str] = ['world', 'world_nether', 'world_the_end'] + stop_timeout: int = 60 + start_timeout: int = 120 + + def validate(self): + for key in ('switch_ratio', 'reset_ratio'): + value = getattr(self, key) + if isinstance(value, bool) or not 0 < value <= 1: + raise ValueError(key) + for key in ('vote_timeout', 'force_timeout', 'stop_timeout', 'start_timeout'): + if type(getattr(self, key)) is not int or getattr(self, key) <= 0: + raise ValueError(key) + if type(self.vote_cooldown) is not int or self.vote_cooldown < 0 or self.list_order not in ('configured', 'activity'): + raise ValueError('invalid vote cooldown or list order') + for key in ('backup_permission', 'cancel_permission'): + if type(getattr(self, key)) is not int or not 0 <= getattr(self, key) <= 4: + raise ValueError(key) + if type(self.list_size) is not int or self.list_size <= 0: + self.list_size = 15 def migrate(self): need_save = False @@ -40,10 +71,14 @@ def load() -> 'MountConfig': config = psi.load_config_simple(file_name=CONFIG_NAME, target_class=MountConfig, in_data_folder=False) setDebugNoCheck(config.debug) config.migrate() + config.validate() return config class SlotStats(Serializable): + schema_version: int = 2 + use_time_ns_v2: int = 0 + player_time_ns_v2: int = 0 last_mount_ns: int = -1 total_use_time: int = 0 total_player_time: int = 0 diff --git a/mount/entry.py b/mount/entry.py index b6f8f70..d856011 100644 --- a/mount/entry.py +++ b/mount/entry.py @@ -1,64 +1,73 @@ -from typing import Optional - -from mcdreforged.api.types import Info, PluginServerInterface +import sys +import types from .cmd_tree import register_commands from .config import MountConfig -from .constants import CONFIG_NAME from .MountManager import MountManager -from .utils import debug, rtr +from .utils import rtr -manager: Optional[MountManager] = None +manager = None +# Deliberately outside the reloadable plugin package. A full unload followed +# by a fresh load (prev_module=None) must still find a running old executor. +_lifecycle = sys.modules.setdefault('_mount_operation_lifecycle', types.ModuleType('_mount_operation_lifecycle')) -def on_load(server: PluginServerInterface, prev_module): - debug(f"plugin loaded") +def on_load(server, prev_module): global manager - config: MountConfig = MountConfig.load() - manager = MountManager(config=config) + previous = getattr(prev_module, 'manager', None) or getattr(_lifecycle, 'owner', None) + # Keep exactly one executor/owner across reload. Never block the event + # thread waiting for startup events required by the old executor. + if previous and previous.executing: + manager = previous + manager.closed = False + _lifecycle.owner = manager + register_commands(server, manager) + return + if previous: + previous.close() + previous.current_slot.release(previous._config.mount_name) + manager = MountManager(MountConfig.load()) + if previous: + manager.cooldowns = dict(previous.cooldowns) + manager.results = dict(previous.results) + manager.players = previous.players + _lifecycle.owner = manager register_commands(server, manager) + if server.is_server_startup(): + manager.on_start() - if manager.current_slot and server.is_server_running(): - manager.current_slot.on_mount() +def on_unload(server): + if manager: + manager.close() -def on_unload(server: PluginServerInterface): - debug(f"plugin unloaded") - if not manager: - return - if manager.current_slot and server.is_server_running(): - manager.current_slot.on_unmount() +def on_mcdr_stop(server): + on_unload(server) -def on_server_startup(server: PluginServerInterface): - debug(f"server started") - if not manager: - return - if manager.current_slot: - manager.current_slot.on_mount() +def on_server_startup(server): + if manager: + manager.on_start() -def on_server_stop(server: PluginServerInterface, code: int): - debug(f"server stopped, code: {code}") - if not manager: - return - if manager.current_slot: - manager.current_slot.on_unmount() +def on_server_stop(server, code): + if manager: + manager.on_stop() -def on_player_joined(server: PluginServerInterface, player: str, info: Info): - debug(f"player joined: {player}") - if not manager: - return - if manager._config.welcome_player: - server.tell(player, rtr('help_msg.welcome')) - if manager.current_slot: - manager.current_slot.on_player_join(player) +def on_info(server, info): + if manager: + manager.on_info(info) -def on_player_left(server: PluginServerInterface, player: str): - debug(f"player left: {player}") - if not manager: - return - if manager.current_slot: - manager.current_slot.on_player_left(player) + +def on_player_joined(server, player, info): + if manager: + manager.player_event(player, True) + if manager._config.welcome_player: + server.tell(player, rtr('help_msg.welcome')) + + +def on_player_left(server, player): + if manager: + manager.player_event(player, False) diff --git a/mount/list_order.py b/mount/list_order.py new file mode 100644 index 0000000..287bcb0 --- /dev/null +++ b/mount/list_order.py @@ -0,0 +1,6 @@ +def order_slots(entries, pinned, mode): + """Stable order of (path, trusted activity) views; never filters entries.""" + ranks = {path: i for i, path in reversed(list(enumerate(pinned)))} + return sorted(entries, key=lambda e: ( + 0 if e[0] in ranks else 1, + ranks[e[0]] if e[0] in ranks else (-e[1] if mode == 'activity' else 0))) diff --git a/mount/players.py b/mount/players.py new file mode 100644 index 0000000..2f3ab76 --- /dev/null +++ b/mount/players.py @@ -0,0 +1,89 @@ +import re +import time +from threading import RLock + + +class PlayerRegistry: + """Only complete server-output snapshots establish trust.""" + def __init__(self): + self.lock = RLock() + self.players = set() + self.trusted = False + self.query = None + self.deltas = [] + self.minimum_id = -1 + self.retry = False + + def invalidate(self, preserve_query=False): + with self.lock: + self.players.clear() + self.trusted = False + if preserve_query and self.query is not None: + # A list reply carries no nonce. Drain an old pending reply + # before issuing a new query in the same server session. + self.query = time.monotonic() - 6 + else: + self.query = None + self.deltas.clear() + self.retry = False + + def begin(self, minimum_id=-1): + with self.lock: + if self.query is not None: + return False + self.trusted = False + self.query = time.monotonic() + self.deltas.clear() + self.minimum_id = minimum_id + self.retry = False + return True + + def event(self, player, joined): + with self.lock: + if joined: + self.players.add(player) + else: + self.players.discard(player) + if self.query is not None: + self.deltas.append((player, joined)) + + def accept(self, info, handler): + with self.lock: + if self.query is None: + return False + if not info.is_from_server or info.is_player or handler not in ('vanilla_handler', 'bukkit_handler'): + return False + if getattr(info, 'id', self.minimum_id+1) <= self.minimum_id: + return False + match = re.fullmatch(r'There are (\d+) (?:of a max of|out of maximum|of a maximum of) \d+ players online:\s*(.*)', info.content) + if not match: + return False + if time.monotonic() - self.query > 5: + self.query = None + self.retry = True + return False + names = [n.strip() for n in match[2].split(',') if n.strip()] + if len(names) != int(match[1]) or len(set(names)) != len(names) or any( + not re.fullmatch(r'[A-Za-z0-9_]{1,16}', n) for n in names): + self.query = None + return False + self.players = set(names) + for player, joined in self.deltas: + if joined: + self.players.add(player) + else: + self.players.discard(player) + self.trusted = True + self.query = None + self.deltas.clear() + return True + + def take_retry(self): + with self.lock: + value = self.retry + self.retry = False + return value + + def snapshot(self): + with self.lock: + return frozenset(self.players) if self.trusted else None diff --git a/mount/reset_helper.py b/mount/reset_helper.py index 9f49be4..75f7508 100644 --- a/mount/reset_helper.py +++ b/mount/reset_helper.py @@ -1,58 +1,58 @@ -import os import shutil +from pathlib import Path -from .utils import logger +from .backup_helper import overlaps, reject_links class ResetHelper: + worlds = ['world', 'world_nether', 'world_the_end'] + reserved = ['playerdata', 'advancements', 'stats'] + @staticmethod - def reset(slot_path, reset_path, reset_type): - reserve_dirs = ['playerdata', 'advancements', 'stats'] - worlds = ['world', 'world_nether', 'world_the_end'] - reset_worlds = filter(lambda x: os.path.isdir(x), - map(lambda x: os.path.join(slot_path, reset_path, x), worlds)) - curr_worlds = filter(lambda x: os.path.isdir(x), - map(lambda x: os.path.join(slot_path, x), worlds)) + def preflight(slot_path, reset_path, reset_type): + slot = Path(slot_path).resolve() + if not reset_path or reset_path == '.' or reset_type not in ('full', 'region'): + raise ValueError('invalid_reset_path') + template = slot / reset_path + reject_links(template) + present = [name for name in ResetHelper.worlds if (template/name).is_dir()] + if not present: + raise ValueError('invalid_reset_path') + for name in ResetHelper.worlds: + reject_links(slot/name) + if overlaps(template, slot/name): + raise ValueError('invalid_reset_path') + return slot, template, present - # reset main world (maybe the only world) - curr_main_world = os.path.join(slot_path, 'world') - reset_main_world = os.path.join(slot_path, reset_path, 'world') - if curr_main_world not in curr_worlds: - pass - elif reset_type == 'region': - dirs = os.listdir(curr_main_world) - for i in map(lambda x: os.path.join(curr_main_world, x), - filter(lambda x: x not in reserve_dirs, dirs)): - logger().info(f'Deleting world/{os.path.basename(i)}...') - if os.path.isdir(i): - shutil.rmtree(i) - else: - os.remove(i) - elif reset_type == 'full': - logger().info('Deleting the whole world/') - shutil.rmtree(curr_main_world) + @staticmethod + def reset(slot_path, reset_path, reset_type): + slot, template, present = ResetHelper.preflight(slot_path, reset_path, reset_type) + # Only worlds actually provided by the template are replaced. + for name in present: + src, dest = template/name, slot/name + if name == 'world' and reset_type == 'region': + dest.mkdir(exist_ok=True) + for child in dest.iterdir(): + if child.name not in ResetHelper.reserved: + ResetHelper.remove(child) + for child in src.iterdir(): + if child.name not in ResetHelper.reserved: + ResetHelper.copy(child, dest/child.name) + else: + if dest.exists(): + ResetHelper.remove(dest) + ResetHelper.copy(src, dest) - if reset_main_world not in reset_worlds: - logger().info('No need to reset world/') - elif reset_type == 'region': - dirs = os.listdir(reset_main_world) - for i in map(lambda x: os.path.join(reset_main_world, x), - filter(lambda x: x not in reserve_dirs, dirs)): - logger().info(f'Resetting world/{os.path.basename(i)}') - if os.path.isdir(i): - shutil.copytree(i, os.path.join(curr_main_world, os.path.basename(i))) - else: - shutil.copy(i, os.path.join(curr_main_world, os.path.basename(i))) - elif reset_type == 'full': - logger().info('Resetting the whole world/') - shutil.copytree(reset_main_world, curr_main_world) + @staticmethod + def remove(path): + if path.is_dir(): + shutil.rmtree(path) + else: + path.unlink() - for i in worlds[1:]: - dir1 = os.path.join(slot_path, i) - dir2 = os.path.join(slot_path, reset_path, i) - if dir1 in curr_worlds: - logger().info(f'Deleting {i}') - shutil.rmtree(dir1) - if dir2 in reset_worlds: - logger().info(f'Resetting {i}') - shutil.copytree(dir2, dir1) \ No newline at end of file + @staticmethod + def copy(src, dest): + if src.is_dir(): + shutil.copytree(src, dest) + else: + shutil.copy2(src, dest) diff --git a/mount/vote.py b/mount/vote.py new file mode 100644 index 0000000..08ebf4e --- /dev/null +++ b/mount/vote.py @@ -0,0 +1,34 @@ +"""Frozen electorate. The manager serializes all mutations.""" +import math +import time +import uuid +from dataclasses import dataclass, field + + +@dataclass +class VoteSession: + kind: str + target: str + owner: str + roster: frozenset + ratio: float + timeout: float + request_id: str = field(default_factory=lambda: uuid.uuid4().hex) + created: float = field(default_factory=time.monotonic) + votes: dict = field(default_factory=dict) + + @property + def threshold(self): + return math.ceil(len(self.roster) * self.ratio) + + @property + def yes(self): + return sum(self.votes.values()) + + def cast(self, player, choice): + if player not in self.roster: + raise ValueError('not_in_roster') + if player in self.votes: + raise ValueError('duplicate_vote') + self.votes[player] = choice + return bool(self.roster) and self.yes >= self.threshold diff --git a/requirements-test-2.7.txt b/requirements-test-2.7.txt new file mode 100644 index 0000000..01a2164 --- /dev/null +++ b/requirements-test-2.7.txt @@ -0,0 +1,5 @@ +jproperties +mcdreforged==2.7.0 +pytest>=7 +setuptools<81 +ruamel.yaml<0.18 diff --git a/requirements-test.txt b/requirements-test.txt new file mode 100644 index 0000000..322ddf7 --- /dev/null +++ b/requirements-test.txt @@ -0,0 +1,4 @@ +-r requirements.txt +pytest>=7 +# MCDR 2.7 imports pkg_resources. +setuptools<81 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..3b8b291 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,135 @@ +import logging +import sys +import types +from pathlib import Path + +import pytest +from mcdreforged.api.types import PlayerCommandSource + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +# PSI acquisition requires a running MCDR plugin context. Only this boundary +# is replaced; command nodes, RText and serialization use actual MCDR APIs. +utils = types.ModuleType('mount.utils') +utils.psi = None +utils.logger = lambda: logging.getLogger('test') +utils.debug = lambda *args: None +utils.setDebugNoCheck = lambda value: None +utils.rtr = lambda translation_key, *args, **kwargs: translation_key + (' ' + str(kwargs) if kwargs else '') +sys.modules['mount.utils'] = utils + + +class Source(PlayerCommandSource): + def __init__(self, player='Alice', permission=0): + self.name = player + self.permission = permission + self.replies = [] + + @property + def player(self): + return self.name + + @property + def is_player(self): + return self.name is not None + + def has_permission(self, level): + return self.permission >= level + + def reply(self, text): + self.replies.append(text) + + +class Server: + def __init__(self): + self.running = self.started = True + self.stops = self.starts = 0 + self.messages = [] + self.stop_works = self.start_works = True + self.config = {'handler': 'vanilla_handler', 'plugin_directories': [], + 'working_directory': '', 'start_command': ''} + + def load_config_simple(self, target_class, file_name, **kwargs): + import json + path = Path(file_name) + if path.exists(): + return target_class.deserialize(json.loads(path.read_text(encoding='utf-8'))) + return target_class() + + def save_config_simple(self, config, file_name, **kwargs): + import json + path = Path(file_name) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(config.serialize()), encoding='utf-8') + + def is_server_running(self): + return self.running + + def is_server_startup(self): + return self.running and self.started + + def stop(self): + self.stops += 1 + if self.stop_works: + self.running = self.started = False + + def start(self): + self.starts += 1 + self.running = True + self.started = self.start_works + + def stop_exit(self): + self.stop() + + def broadcast(self, text): + self.messages.append(text) + + def tell(self, player, text): + self.messages.append(text) + + def execute(self, command): + pass + + def get_mcdr_config(self): + return self.config.copy() + + def modify_mcdr_config(self, changes): + self.config.update(changes) + + +@pytest.fixture +def environment(tmp_path, monkeypatch): + import mount.MountManager as mm + import mount.MountSlot as ms + import mount.config as cfg + import mount.entry as entry + monkeypatch.delattr(entry._lifecycle, 'owner', raising=False) + from mount.constants import MOUNTABLE_CONFIG + server = Server() + monkeypatch.setattr(cfg, 'CONFIG_NAME', str(tmp_path/'instance.json')) + for module in (mm, ms, cfg): + monkeypatch.setattr(module, 'psi', server) + config = cfg.MountConfig() + config.current_server = str(tmp_path/'current') + config.available_servers = [config.current_server, str(tmp_path/'target with spaces')] + config.backup_root = str(tmp_path/'backups') + config.vote_cooldown = 0 + config.stop_timeout = config.start_timeout = 1 + config.force_players = ['Admin'] + config.overwrite_path = '' + for path in config.available_servers: + Path(path, 'world').mkdir(parents=True) + Path(path, 'world', 'level.dat').write_bytes(b'world-data') + slot = cfg.SlotConfig() + slot.checked = True + slot.reset_path = 'template' + Path(path, 'template', 'world').mkdir(parents=True) + Path(path, 'template', 'world', 'level.dat').write_bytes(b'template') + server.save_config_simple(slot, str(Path(path, MOUNTABLE_CONFIG))) + manager = mm.MountManager(config) + manager.players.trusted = True + manager.players.players = {'Alice', 'Bob', 'Admin'} + yield manager, server, Source + if manager.worker: + manager.worker.join(5) + assert not manager.worker.is_alive() + manager.close() diff --git a/tests/isolated_mcdr.py b/tests/isolated_mcdr.py new file mode 100644 index 0000000..1149d26 --- /dev/null +++ b/tests/isolated_mcdr.py @@ -0,0 +1,168 @@ +"""Foreground real-MCDR smoke test with a disposable vanilla-output process. + +Run with the same Python that has MCDR installed. No Java/world downloads, +network listener, production paths, or background service are used. +""" +import json +import queue +import re +import shutil +import subprocess +import sys +import tempfile +import threading +import time +import zipfile +from pathlib import Path + + +FAKE_SERVER = '''import sys, time, json +def plain(value): + if isinstance(value, list): + return ''.join(plain(v) for v in value) + if isinstance(value, dict): + return value.get('text','')+plain(value.get('extra',[])) + return str(value) +def emit(text): + print('[12:00:00] [Server thread/INFO]: '+text, flush=True) +emit('Starting minecraft server version 1.20.1') +emit('Done (0.01s)! For help, type "help"') +for line in sys.stdin: + line = line.strip().lstrip('/') + if line.startswith('execute at @p run '): + line = line[len('execute at @p run '):] + if line == 'stop': + emit('Stopping server') + time.sleep(.25) + break + elif line == 'list': + emit('There are 3 of a max of 20 players online: Alice, Bob, Admin') + elif line.startswith('testchat '): + _, name, command = line.split(' ',2) + emit('<'+name+'> '+command) + elif line.startswith('tellraw '): + emit('TEST_REPLY '+line+' PLAIN '+plain(json.loads(line.split(' ',2)[2]))) +''' + + +def main(): + repository = Path(__file__).resolve().parents[1] + sys.path.insert(0, str(repository)) + with tempfile.TemporaryDirectory(prefix='mount-isolated-') as temporary: + root = Path(temporary) + subprocess.run([sys.executable,'-m','mcdreforged','init'],cwd=root,check=True, + stdout=subprocess.DEVNULL) + from ruamel.yaml import YAML + yaml = YAML() + config_path = root/'config.yml' + config = yaml.load(config_path) + current, target = root/'current', root/'target with spaces' + start_command = '"{}" fake_server.py'.format(sys.executable) + for slot in (current,target): + (slot/'world'/'playerdata').mkdir(parents=True) + (slot/'world'/'level.dat').write_bytes(b'disposable-current') + (slot/'world'/'playerdata'/'Alice.dat').write_bytes(b'player') + (slot/'template'/'world').mkdir(parents=True) + (slot/'template'/'world'/'level.dat').write_bytes(b'disposable-template') + (slot/'fake_server.py').write_text(FAKE_SERVER,encoding='utf-8') + (slot/'mount.json').write_text(json.dumps({'checked':True,'start_command':start_command, + 'handler':'vanilla_handler','reset_path':'template','reset_type':'region'})) + from mount.constants import MOUNTABLE_CONFIG, CONFIG_NAME + if MOUNTABLE_CONFIG != 'mount.json': + for slot in (current,target): + (slot/'mount.json').rename(slot/MOUNTABLE_CONFIG) + config.update({'working_directory':str(current),'start_command':start_command, + 'handler':'vanilla_handler','language':'en_us','check_update':False, + 'advanced_console':False}) + yaml.dump(config,config_path) + (root/CONFIG_NAME).parent.mkdir(parents=True, exist_ok=True) + (root/CONFIG_NAME).write_text(json.dumps({'current_server':str(current), + 'available_servers':[str(current),str(target)],'servers_path':[str(root/'unused')], + 'overwrite_path':'','mount_name':'isolated','vote_cooldown':0, + 'force_players':['Admin'],'force_console':False, + 'backup_root':str(root/'backups'),'start_timeout':10,'stop_timeout':10}),encoding='utf-8') + with zipfile.ZipFile(root/'plugins'/'Mount.pyz','w') as archive: + for folder in ('mount','lang'): + for path in (repository/folder).rglob('*'): + if path.is_file() and '__pycache__' not in path.parts: + archive.write(path,path.relative_to(repository)) + archive.write(repository/'mcdreforged.plugin.json','mcdreforged.plugin.json') + process = subprocess.Popen([sys.executable,'-m','mcdreforged','start'],cwd=root, + stdin=subprocess.PIPE,stdout=subprocess.PIPE,stderr=subprocess.STDOUT, + text=True,encoding='utf-8',errors='replace') + lines, output = queue.Queue(), [] + def read(): + for line in process.stdout: + output.append(line) + lines.put(re.sub(r'\x1b\[[0-9;]*m', '', line)) + # Closing a pipe before modern MCDR stops its console creates + # an EOF input flood. Release the reader only after console stop. + if '[MainThread/INFO]: bye' in line and not process.stdin.closed: + process.stdin.close() + reader = threading.Thread(target=read) + reader.start() + def send(command): + process.stdin.write(command+'\n') + process.stdin.flush() + def wait(pattern,timeout=20): + deadline = time.monotonic()+timeout + while time.monotonic() Date: Sun, 4 Oct 2026 03:51:23 +0800 Subject: [PATCH 2/4] fix(401U-8): expose vote scope and rules and clarify private server lists Co-authored-by: multica-agent --- .gitignore | 1 + docs/migration.md | 9 ++ docs/verification.md | 18 ++++ lang/en_us.yml | 11 ++- lang/zh_cn.yml | 11 ++- mount/MountManager.py | 90 ++++++++++++++----- mount/vote.py | 1 + tests/isolated_mcdr.py | 55 +++++++++--- tests/test_acceptance_independent.py | 125 +++++++++++++++++++++++++++ tests/test_interaction_revision.py | 113 ++++++++++++++++++++++++ 10 files changed, 395 insertions(+), 39 deletions(-) create mode 100644 tests/test_acceptance_independent.py create mode 100644 tests/test_interaction_revision.py diff --git a/.gitignore b/.gitignore index 6e150dd..756019f 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ .venv-*/ .pytest_cache/ isolated-mcdr.log +rendered-*.txt *.mcdr *.pyz */__pycache__/ diff --git a/docs/migration.md b/docs/migration.md index d911fae..de3c19b 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -120,3 +120,12 @@ Unload cancels pending sessions and lets an executor finish recovery. Reload dur execution reuses that executor's manager; reload again after completion to load new instance configuration. File-based occupied_by retains its pre-existing cross-instance TOCTOU limitation. Merge, release and production-world operations are outside this change. +## 列表与投票展示补充 / List and vote presentation + +列表正文使用服务器目录名;重名以配置顺序编号消歧。按钮使用完整 SHA-256 路径令牌,后台仍精确匹配配置路径,手动输入完整路径继续兼容。令牌目标移除后拒绝执行。正文区分置顶、当前、可用、占用、未检查及目录/配置失效;边界翻页不可点击,无效页码返回第 1 页。任一活跃度不可验证时,整体降级为置顶加配置顺序并说明原因,不隐藏不可用项。 + +List text uses directory names, with configured-order numbers for duplicates. Buttons use full SHA-256 path tokens resolved to exact configured paths; manual full paths remain supported. Removed token targets are rejected. Rows distinguish pinned/current/available/occupied/unchecked/missing/invalid states. Boundary navigation is inactive; invalid pages return to page 1. Untrusted activity falls back to pinned then configured order with an explicit explanation, preserving unavailable entries. + +投票创建广播包含实际比例、赞成门槛、冻结人数、剩余时间及进出规则。重置创建广播与 status 在赞成按钮之前显示本轮预检得到的模式、世界、模板名称和 full/region 玩家数据后果;不增加投票前确认。 + +Vote creation broadcasts include ratio, threshold, frozen electorate, remaining time and join/leave rules. Reset broadcasts and status show the preflight scope (mode, worlds, template name and full/region player-data consequences) before Yes buttons, without adding confirmation before voting. diff --git a/docs/verification.md b/docs/verification.md index 5493bdc..c5def22 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -79,3 +79,21 @@ tree on failure. `isolated-mcdr.log` is generated locally and intentionally untr 中文:自动测试与真实 MCDR 进程的可控服务端模拟通过,不等于真实 Java 游戏服务 验收。实际部署版本、Bukkit/本地化输出及真实游戏存档须由阶段 3 独立验证。 未执行合并、正式发布或生产存档操作。 +## 401U-8 独立验收修订 + +固定旧 SHA `13c36adcd18ea15e531e61d65914bae7bda946aa` 运行独立补验得到 8 passed / 4 failed;保留原脚本全部 12 项功能断言,未删减要求。修复广播/状态重置范围、创建投票门槛和列表展示。新增 11 项语义测试覆盖非法页码及两端导航、四种不可用原因、重名精确目标与路径隐藏、活跃度整体降级、中英文 full/region 文本在 Yes 前展示。 + +Windows / Python 3.11,MCDR 2.7.0 和 2.16.0 各 66 passed(原 43 + 独立 12 + 新增 11)。2.7 有两项依赖弃用警告。复现: + +```text +uv run --python 3.11 --with-requirements requirements-test-2.7.txt python -m pytest -q tests +uv run --python 3.11 --with-requirements requirements-test.txt --with mcdreforged==2.16.0 python -m pytest -q tests +uv run --python 3.11 --with-requirements requirements-test-2.7.txt python tests/isolated_mcdr.py en_us +uv run --python 3.11 --with-requirements requirements-test-2.7.txt python tests/isolated_mcdr.py zh_cn +uv run --python 3.11 --with-requirements requirements-test.txt --with mcdreforged==2.16.0 python tests/isolated_mcdr.py en_us +uv run --python 3.11 --with-requirements requirements-test.txt --with mcdreforged==2.16.0 python tests/isolated_mcdr.py zh_cn +``` + +四个隔离进程流程均有成功通过记录,覆盖真实 MCDR 插件加载、列表/边界按钮、玩家快照、切换比例、含空格路径切服、其他选民的重置 scope/status、取消、备份、full/region 重置、执行中 reload 和退出。最终批次中 2.7 中文流程一次完成动作并输出 bye 后非零退出,随后两次复跑均成功;该退出偶发尚未定位,不宣称进程稳定性已证明。服务端仍为 Python vanilla 输出模拟,不是 Java。中文测试环境显式配置 UTF-8 编解码,避免 Windows 默认 GBK 与模拟进程 UTF-8 不匹配;真实部署编码未验证。`rendered-en_us.txt` / `rendered-zh_cn.txt` 是实际 tellraw JSON 平展后的纯文本证据(换行显示为 ` | `),不代表客户端截图。单测另读取实际语言 YAML 并检查 RText 内容与按钮先后。 + +真实 Java 服务端、认证客户端、EULA 设置及实际部署 MCDR 版本未提供,仍未验证。原跨实例 TOCTOU、重置部分失败须人工恢复及 CI artifact v3/workflow 权限限制保留。未发布、合并、接受 EULA 或操作生产世界。 diff --git a/lang/en_us.yml b/lang/en_us.yml index 24a517d..8d5a87c 100644 --- a/lang/en_us.yml +++ b/lang/en_us.yml @@ -35,7 +35,16 @@ mount: cooldown: 'This operation is cooling down. Try again later.' stale: 'No matching active request; this button has expired.' recorded: 'Vote recorded.' - vote_started: '{actor} started {action} vote: {target}. Request {id}. The initiator must vote explicitly.' + vote_started: '{actor} started {action} vote: {target}. Request {id}. Ratio {ratio}%, required {required}/{total} frozen players, {seconds}s left. New arrivals excluded; departures do not lower threshold. The initiator must vote explicitly.' + pinned: '[Pinned]' + current: '[Current]' + available: '[Available]' + occupied: '[Unavailable: occupied]' + unchecked: '[Unavailable: unchecked]' + missing: '[Unavailable: directory missing]' + invalid: '[Unavailable: invalid configuration]' + page_corrected: 'Invalid page; showing page 1.' + activity_fallback: 'Activity data is untrusted; using configured order after pinned servers.' status: '{action}: {target}; initiator {actor}; request {id}. Yes {yes}/{required}, electorate {total}, No {no}, {seconds}s left. New arrivals excluded; departures do not lower threshold.' cancelled: 'Vote {id} cancelled; no action executed.' expired: 'Vote {id} timed out; no action executed.' diff --git a/lang/zh_cn.yml b/lang/zh_cn.yml index 4ddc210..9a705ed 100644 --- a/lang/zh_cn.yml +++ b/lang/zh_cn.yml @@ -35,7 +35,16 @@ mount: cooldown: '此类操作仍在冷却,请稍后重试。' stale: '没有匹配的活动请求,此按钮已失效。' recorded: '已记录投票。' - vote_started: '{actor} 发起{action}投票:{target}。请求 {id}。发起者也须显式投票。' + vote_started: '{actor} 发起{action}投票:{target}。请求 {id}。比例 {ratio}%,须 {required}/{total} 名冻结选民赞成,剩余 {seconds} 秒。新加入不计入,离开不降低门槛。发起者也须显式投票。' + pinned: '[置顶]' + current: '[当前服]' + available: '[可用]' + occupied: '[不可用:已占用]' + unchecked: '[不可用:未检查]' + missing: '[不可用:目录不存在]' + invalid: '[不可用:配置失效]' + page_corrected: '页码无效,已返回第 1 页。' + activity_fallback: '活跃度数据不可信,置顶后按配置顺序显示。' status: '{action}:{target};发起者 {actor};请求 {id}。赞成 {yes}/{required},冻结名单 {total} 人,反对 {no},剩余 {seconds} 秒。新加入不计入,离开不降低门槛。' cancelled: '投票 {id} 已取消,未执行操作。' expired: '投票 {id} 已超时,未执行操作。' diff --git a/mount/MountManager.py b/mount/MountManager.py index ea17b5b..fa12552 100644 --- a/mount/MountManager.py +++ b/mount/MountManager.py @@ -1,4 +1,5 @@ import math +import hashlib import os import time import uuid @@ -76,6 +77,23 @@ def button(self, key, command): def action_text(self, kind): return rtr('flow.action_' + kind) + def target_token(self, path): + return 'slot:' + hashlib.sha256(path.encode('utf-8')).hexdigest() + + def resolve_target(self, target): + if target.startswith('slot:'): + return next((path for path in self.servers_as_list if self.target_token(path) == target), '') + return target + + def target_name(self, path): + name = os.path.basename(os.path.normpath(path)) + matches = [p for p in self.servers_as_list if os.path.basename(os.path.normpath(p)) == name] + return name + (' #'+str(matches.index(path)+1) if len(matches) > 1 and path in matches else '') + + def reset_scope(self, slot): + _, template, present = ResetHelper.preflight(slot.path, slot.reset_path, slot.reset_type) + return rtr('flow.reset_scope', mode=slot.reset_type, worlds=', '.join(present), template=template.name) + def restore_players(self): if self.closed: return @@ -137,7 +155,7 @@ def available(self): raise ValueError('busy') def request_mount(self, source, path, with_confirm=False): - self.request_vote(source, 'switch', path) + self.request_vote(source, 'switch', self.resolve_target(path)) def request_reset(self, source): self.request_vote(source, 'reset', self.current_slot.path) @@ -145,6 +163,7 @@ def request_reset(self, source): def request_vote(self, src, kind, target): try: target_slot = self.validate_target(kind, target) + scope = self.reset_scope(target_slot) if kind == 'reset' else None with self.lock: self.available() if self.vote: @@ -161,17 +180,19 @@ def request_vote(self, src, kind, target): self.vote = VoteSession(kind, target, src.player, roster, getattr(self._config, kind + '_ratio'), self._config.vote_timeout) vote = self.vote + vote.reset_scope = scope self.timer = Timer(vote.timeout, self.expire_vote, [vote.request_id]) self.timer.daemon = True self.timer.start() self.show_status(src, vote.request_id) psi.broadcast(RTextList(rtr('flow.vote_started', actor=vote.owner, - action=self.action_text(kind), target=target, id=vote.request_id), ' ', + action=self.action_text(kind), target=self.target_name(target), id=vote.request_id, + ratio=vote.ratio*100, required=vote.threshold, total=len(vote.roster), + seconds=max(0, math.ceil(vote.created+vote.timeout-time.monotonic()))), + RTextList('\n', scope) if scope is not None else '', ' ', self.button('yes', 'vote '+vote.request_id+' yes'), ' ', self.button('no', 'vote '+vote.request_id+' no'), ' ', self.button('progress', 'status '+vote.request_id))) - if kind == 'reset': - self.reset_summary(src, target_slot) except (ValueError, ResourceWarning) as exc: self.message(src, str(exc) if isinstance(exc, ValueError) else 'invalid_target') @@ -196,9 +217,10 @@ def show_status(self, src, request_id=None): if not vote or request_id and vote.request_id != request_id: self.message(src, 'stale') return - text = RTextList(rtr('flow.status', id=vote.request_id, action=self.action_text(vote.kind), target=vote.target, + text = RTextList(rtr('flow.status', id=vote.request_id, action=self.action_text(vote.kind), target=self.target_name(vote.target), actor=vote.owner, yes=vote.yes, required=vote.threshold, total=len(vote.roster), no=len(vote.votes)-vote.yes, seconds=max(0, math.ceil(vote.created+vote.timeout-time.monotonic()))), + RTextList('\n', vote.reset_scope) if vote.reset_scope is not None else '', ' ', self.button('yes', 'vote ' + vote.request_id + ' yes'), ' ', self.button('no', 'vote ' + vote.request_id + ' no')) if self.identity(src) == vote.owner or src.has_permission(self._config.cancel_permission): @@ -251,8 +273,7 @@ def cancel(self, src, request_id=None): confirm_operation = show_status def reset_summary(self, src, slot): - _, template, present = ResetHelper.preflight(slot.path, slot.reset_path, slot.reset_type) - self.message(src, 'reset_scope', mode=slot.reset_type, worlds=', '.join(present), template=template.name) + src.reply(self.reset_scope(slot)) @staticmethod def slot_contract(slot): @@ -262,7 +283,7 @@ def request_force(self, src, kind, target=None): if not self.can_force(src): self.message(src, 'permission') return - target = target or self.current_slot.path + target = self.resolve_target(target) if target else self.current_slot.path try: target_slot = self.validate_target(kind, target) with self.lock: @@ -278,7 +299,7 @@ def request_force(self, src, kind, target=None): self.force_timer.daemon = True self.force_timer.start() old = self.force.vote_id or '-' - src.reply(RTextList(rtr('flow.force_summary', action=self.action_text(kind), target=target, id=request_id, old=old), + src.reply(RTextList(rtr('flow.force_summary', action=self.action_text(kind), target=self.target_name(target), id=request_id, old=old), ' ', self.button('confirm', 'force confirm ' + request_id), ' ', self.button('cancel_force', 'cancel ' + request_id))) if kind == 'reset': @@ -490,49 +511,70 @@ def close(self): psi.broadcast(rtr('flow.cancelled', id=old)) def list_servers(self, src, page=1): - views, slots = [], {} + views, slots, reasons = [], {}, {} + degraded = False for path in self.servers_as_list: try: if not os.path.isdir(path): raise ValueError('missing path') slot = MountSlot(path) - slots[path] = slot - score = slot.stats.player_time_ns_v2 if slot.stats.schema_version == 2 else 0 + score = slot.stats.player_time_ns_v2 + if slot.stats.schema_version != 2 or type(score) not in (int, float) or not math.isfinite(score) or score < 0: + score, degraded = 0, True views.append((path, max(0, score))) + slots[path] = slot except Exception: + reasons[path] = 'missing' if not os.path.isdir(path) else 'invalid' + degraded = True views.append((path, 0)) - views = order_slots(views, self._config.pinned_servers, self._config.list_order) + views = order_slots(views, self._config.pinned_servers, + 'configured' if degraded else self._config.list_order) size = self._config.list_size if not isinstance(size, int) or size <= 0: size = 15 pages = max(1, math.ceil(len(views)/size)) - page = max(1, min(pages, int(page))) + try: + page = int(page) + except (ValueError, TypeError): + page = 0 + if not 1 <= page <= pages: + page = 1 + self.message(src, 'page_corrected') src.reply(rtr('list.title')) + if degraded and self._config.list_order == 'activity': + self.message(src, 'activity_fallback') for path, _ in views[(page-1)*size:page*size]: slot, current = slots.get(path), path == self.current_slot.path - row = RTextList(path, ' ') + row = RTextList(self.target_name(path), ' ') + if path in self._config.pinned_servers: + row.append(rtr('flow.pinned'), ' ') + if current: + row.append(rtr('flow.current'), ' ') if slot: row.append(slot.desc, ' ') - if current and slot.reset_path: + reason = ('unchecked' if not slot.checked else 'occupied' + if slot.occupied_by and (not current or slot.occupied_by != self._config.mount_name) + else 'available') + row.append(rtr('flow.'+reason), ' ') + if current and slot.reset_path and reason == 'available': row.append(self.button('reset', 'reset')) - elif not current and slot.checked and not slot.occupied_by: - row.append(self.button('switch', 'switch '+path)) - else: - row.append(rtr('flow.unavailable')) + elif not current and reason == 'available': + row.append(self.button('switch', 'switch '+self.target_token(path))) else: - row.append(rtr('flow.unavailable')) + row.append(rtr('flow.'+reasons[path])) if current and self.can_backup(src): row.append(' ', self.button('backup', 'backup')) src.reply(row) if not views: src.reply(rtr('list.empty')) - src.reply(RTextList(self.button('previous', 'list '+str(max(1,page-1))), - ' {}/{} '.format(page,pages), self.button('next', 'list '+str(min(pages,page+1))))) + src.reply(RTextList(self.button('previous', 'list '+str(page-1)) if page > 1 else rtr('flow.previous'), + ' {}/{} '.format(page,pages), self.button('next', 'list '+str(page+1)) if page < pages else rtr('flow.next'))) if self.can_force(src): src.reply(RTextList(rtr('flow.management'), ' ', self.button('force_reset', 'force reset'))) for path, _ in views: if path != self.current_slot.path: - src.reply(self.button('force_switch', 'force switch '+path)) + src.reply(RTextList(self.target_name(path), ' ', + self.button('force_switch', 'force switch '+self.target_token(path)))) def get_config(self, key, src=None): value = getattr(self._config, key) diff --git a/mount/vote.py b/mount/vote.py index 08ebf4e..972ec1e 100644 --- a/mount/vote.py +++ b/mount/vote.py @@ -16,6 +16,7 @@ class VoteSession: request_id: str = field(default_factory=lambda: uuid.uuid4().hex) created: float = field(default_factory=time.monotonic) votes: dict = field(default_factory=dict) + reset_scope: object = None @property def threshold(self): diff --git a/tests/isolated_mcdr.py b/tests/isolated_mcdr.py index 1149d26..0aeeab7 100644 --- a/tests/isolated_mcdr.py +++ b/tests/isolated_mcdr.py @@ -4,6 +4,7 @@ network listener, production paths, or background service are used. """ import json +import os import queue import re import shutil @@ -41,12 +42,13 @@ def emit(text): _, name, command = line.split(' ',2) emit('<'+name+'> '+command) elif line.startswith('tellraw '): - emit('TEST_REPLY '+line+' PLAIN '+plain(json.loads(line.split(' ',2)[2]))) + emit('TEST_REPLY '+line+' PLAIN '+plain(json.loads(line.split(' ',2)[2])).replace(chr(10),' | ')) ''' def main(): repository = Path(__file__).resolve().parents[1] + language = sys.argv[1] if len(sys.argv) > 1 else 'en_us' sys.path.insert(0, str(repository)) with tempfile.TemporaryDirectory(prefix='mount-isolated-') as temporary: root = Path(temporary) @@ -72,12 +74,13 @@ def main(): for slot in (current,target): (slot/'mount.json').rename(slot/MOUNTABLE_CONFIG) config.update({'working_directory':str(current),'start_command':start_command, - 'handler':'vanilla_handler','language':'en_us','check_update':False, - 'advanced_console':False}) + 'handler':'vanilla_handler','language':language,'check_update':False, + 'advanced_console':False, 'encoding':'utf-8', 'decoding':'utf-8'}) yaml.dump(config,config_path) (root/CONFIG_NAME).parent.mkdir(parents=True, exist_ok=True) (root/CONFIG_NAME).write_text(json.dumps({'current_server':str(current), 'available_servers':[str(current),str(target)],'servers_path':[str(root/'unused')], + 'pinned_servers':[str(current)], 'overwrite_path':'','mount_name':'isolated','vote_cooldown':0, 'force_players':['Admin'],'force_console':False, 'backup_root':str(root/'backups'),'start_timeout':10,'stop_timeout':10}),encoding='utf-8') @@ -89,6 +92,7 @@ def main(): archive.write(repository/'mcdreforged.plugin.json','mcdreforged.plugin.json') process = subprocess.Popen([sys.executable,'-m','mcdreforged','start'],cwd=root, stdin=subprocess.PIPE,stdout=subprocess.PIPE,stderr=subprocess.STDOUT, + env=dict(os.environ, PYTHONIOENCODING='utf-8'), text=True,encoding='utf-8',errors='replace') lines, output = queue.Queue(), [] def read(): @@ -119,39 +123,59 @@ def wait(pattern,timeout=20): raise AssertionError('Missing '+pattern+'\n'+''.join(output[-40:])) try: wait(r'3 of a max of 20 players online') + send('testchat Alice !!mount list') + wait(r'PLAIN .*'+('Available' if language == 'en_us' else '可用')) + navigation = wait(r'TEST_REPLY tellraw Alice .*1/1').string + assert 'clickEvent' not in navigation send('testchat Alice !!mount switch '+str(target)) - vote_id = wait(r'Alice started switch vote.*Request ([a-f0-9]{32})')[1] + creation = wait(r'Alice started switch vote.*Request ([a-f0-9]{32})' if language == 'en_us' + else r'Alice 发起切换投票.*请求 ([a-f0-9]{32})') + vote_id = creation[1] + assert '60.0%' in creation.string and '2/3' in creation.string send('testchat Alice !!mount vote '+vote_id+' yes') send('testchat Bob !!mount vote '+vote_id+' yes') - wait(r'switch request '+vote_id+r'.*Startup verified') + wait(r'switch request '+vote_id+r'.*Startup verified' if language == 'en_us' + else r'切换 请求 '+vote_id+r'.*已验证启动完成') assert json.loads((root/CONFIG_NAME).read_text())['current_server']==str(target) + send('testchat Alice !!mount reset') + reset_id = wait(r'Alice started reset vote.*Request ([a-f0-9]{32})' if language == 'en_us' + else r'Alice 发起重置投票.*请求 ([a-f0-9]{32})')[1] + send('testchat Bob !!mount status '+reset_id) + wait(r'TEST_REPLY tellraw Bob .*PLAIN .*'+('Reset mode region' if language == 'en_us' else '重置模式 region')) + send('testchat Alice !!mount cancel '+reset_id) + wait(r'Vote '+reset_id+r' cancelled' if language == 'en_us' else r'投票 '+reset_id+r' 已取消') send('!!mount backup') - wait(r'Server is stopping briefly') + wait(r'Server is stopping briefly' if language == 'en_us' else r'服务器将短暂停止') send('!!MCDR plugin reload mount') - backup_id = wait(r'backup request .*verified backup ([a-f0-9]{32}).*Startup verified')[1] + backup_id = wait(r'backup request .*verified backup ([a-f0-9]{32}).*Startup verified' if language == 'en_us' + else r'备份 请求 .*已校验备份 ([a-f0-9]{32}).*已验证启动完成')[1] assert (root/'backups'/backup_id/'manifest.json').is_file() send('testchat Admin !!mount force reset') - force_id = wait(r'Force reset:.*Request ([a-f0-9]{32})')[1] + force_id = wait(r'Force reset:.*Request ([a-f0-9]{32})' if language == 'en_us' + else r'强制重置:.*请求 ([a-f0-9]{32})')[1] send('testchat Admin !!mount force confirm '+force_id) - wait(r'reset request '+force_id+r'.*Startup verified') + wait(r'reset request '+force_id+r'.*Startup verified' if language == 'en_us' + else r'重置 请求 '+force_id+r'.*已验证启动完成') assert (target/'world'/'level.dat').read_bytes()==b'disposable-template' assert (target/'world'/'playerdata'/'Alice.dat').read_bytes()==b'player' slot_config = json.loads((target/MOUNTABLE_CONFIG).read_text()) slot_config['reset_type'] = 'full' (target/MOUNTABLE_CONFIG).write_text(json.dumps(slot_config)) send('testchat Admin !!mount force reset') - force_id = wait(r'Force reset:.*Request ([a-f0-9]{32})')[1] + force_id = wait(r'Force reset:.*Request ([a-f0-9]{32})' if language == 'en_us' + else r'强制重置:.*请求 ([a-f0-9]{32})')[1] send('testchat Admin !!mount force confirm '+force_id) - wait(r'reset request '+force_id+r'.*Startup verified') + wait(r'reset request '+force_id+r'.*Startup verified' if language == 'en_us' + else r'重置 请求 '+force_id+r'.*已验证启动完成') assert not (target/'world'/'playerdata').exists() send('!!MCDR plugin reload mount') wait(r'3 of a max of 20 players online') send('stop') wait(r'bye') process.wait(timeout=20) - assert process.returncode==0 + assert process.returncode==0, 'Exit '+str(process.returncode)+'\n'+''.join(output[-25:]) assert not any('Error loading plugin' in line or 'Error when executing' in line for line in output) - print('PASS: real MCDR load, snapshot, vote, spaced-path switch, backup, full/region reset, reload during execution, shutdown') + print('PASS '+language+': real MCDR load, list, snapshot, vote, reset scope for other voters, spaced-path switch, backup, full/region reset, reload during execution, shutdown') finally: if process.poll() is None: import psutil @@ -162,6 +186,11 @@ def wait(pattern,timeout=20): process.wait(timeout=10) reader.join(timeout=10) (repository/'isolated-mcdr.log').write_text(''.join(output),encoding='utf-8') + rendered = [line.split(' PLAIN ', 1)[1].strip() for line in output if ' PLAIN ' in line + and ('started switch vote' in line or '发起切换投票' in line + or 'Reset mode' in line or '重置模式' in line + or '[Available]' in line or '[可用]' in line)] + (repository/('rendered-'+language+'.txt')).write_text('\n'.join(dict.fromkeys(rendered)), encoding='utf-8') if __name__ == '__main__': diff --git a/tests/test_acceptance_independent.py b/tests/test_acceptance_independent.py new file mode 100644 index 0000000..680da29 --- /dev/null +++ b/tests/test_acceptance_independent.py @@ -0,0 +1,125 @@ +"""Independent acceptance probes; two assertions document contract defects.""" +import re +import threading +from pathlib import Path + +import pytest + +from test_commands_stats import CommandUser, command_root + + +def test_other_reset_voter_receives_scope_before_yes(environment): + m, server, Source = environment + m.request_reset(Source('Alice')) + bob = Source('Bob') + m.show_status(bob, m.vote.request_id) + visible = '\n'.join(map(str, server.messages + bob.replies)) + assert 'reset_scope' in visible, visible + + +def test_creation_broadcast_contains_threshold_and_timeout(environment): + m, server, Source = environment + m.request_mount(Source(), m.servers_as_list[1]) + visible = '\n'.join(map(str, server.messages)) + assert 'required' in visible and 'seconds' in visible, visible + + +def test_list_marks_pinned_current_and_disables_border_actions(environment): + m, server, Source = environment + m._config.pinned_servers = [m.current_slot.path] + src = Source() + m.list_servers(src,1) + visible = '\n'.join(map(str,src.replies)) + assert 'pinned' in visible.lower() and 'current' in visible.lower() and 'flow.unavailable' not in visible, visible + navigation = src.replies[-1].to_json_object() + assert 'clickEvent' not in str(navigation), navigation + + +def test_list_border_buttons_have_no_click_action(environment): + m, server, Source = environment + src = Source() + m.list_servers(src,1) + navigation = src.replies[-1].to_json_object() + assert 'clickEvent' not in str(navigation), navigation + + +def test_three_independent_capabilities_and_default_force_denial(environment): + m, server, _ = environment + root = command_root(m) + for name, permission, backup, force in [('Alice',0,False,False),('Bob',3,True,False),('Admin',0,False,True)]: + src = CommandUser(name,permission) + root.execute(src,'!!mount') + root.execute(src,'!!mount list') + output = '\n'.join(map(str,src.replies)) + assert ('backup' in output) == backup + assert ('force ' in output) == force + m._config.force_players = [] + m.request_force(CommandUser('Admin',3),'reset') + m.request_force(CommandUser(None,4),'reset') + assert m.force is None and server.stops == 0 + + +def test_force_permission_revoked_after_summary(environment): + m, server, Source = environment + m.request_force(Source('Admin'),'reset') + request = m.force.request_id + m._config.force_players = [] + m.confirm_force(Source('Admin'),request) + assert m.executing is None and server.stops == 0 + + +def test_old_yes_button_cannot_vote_on_new_request(environment): + m, server, Source = environment + m.request_mount(Source(),m.servers_as_list[1]) + old = m.vote.request_id + m.cancel(Source(),old) + m.request_mount(Source(),m.servers_as_list[1]) + m.cast_vote(Source(),old,True) + assert m.vote.yes == 0 and server.stops == 0 + + +def test_vote_force_cancel_race_at_most_one_execution(environment): + m, server, Source = environment + m.request_mount(Source(),m.servers_as_list[1]) + vote = m.vote.request_id + m.cast_vote(Source(),vote,True) + m.request_force(Source('Admin'),'switch',m.servers_as_list[1]) + force = m.force.request_id + barrier = threading.Barrier(3) + errors = [] + def run(fn): + try: + barrier.wait(5) + fn() + except BaseException as exc: + errors.append(exc) + threads = [threading.Thread(target=run,args=(fn,)) for fn in ( + lambda:m.cast_vote(Source('Bob'),vote,True), + lambda:m.confirm_force(Source('Admin'),force), + lambda:m.cancel(Source(),vote))] + for thread in threads: thread.start() + for thread in threads: + thread.join(5) + assert not thread.is_alive() + if m.worker: m.worker.join(5) + assert not errors and server.stops <= 1 and m.executing is None + + +@pytest.mark.parametrize('n',[1,3,5]) +def test_actual_manager_rounding_for_both_actions(environment,n): + m, server, Source = environment + m.players.players = {'Alice'} | {'P'+str(i) for i in range(n-1)} + for kind,expected in [('switch',{1:1,3:2,5:3}[n]),('reset',n)]: + m.request_vote(Source(),kind,m.servers_as_list[1] if kind=='switch' else m.current_slot.path) + assert m.vote.threshold == expected and m.vote.yes == 0 + m.cancel(Source(),m.vote.request_id) + + +def test_language_fields_match_and_utf8_is_valid(): + from ruamel.yaml import YAML + root = Path(__file__).resolve().parents[1] + data = [YAML().load((root/'lang'/name).read_text(encoding='utf-8'))['mount']['flow'] for name in ['en_us.yml','zh_cn.yml']] + assert data[0].keys() == data[1].keys() + for key in data[0]: + assert set(re.findall(r'{(\w+)}',data[0][key])) == set(re.findall(r'{(\w+)}',data[1][key])) + assert '\ufffd' not in data[1][key] diff --git a/tests/test_interaction_revision.py b/tests/test_interaction_revision.py new file mode 100644 index 0000000..0ae1933 --- /dev/null +++ b/tests/test_interaction_revision.py @@ -0,0 +1,113 @@ +import json +from pathlib import Path + +import pytest + + +def test_invalid_page_and_navigation(environment): + m, _, Source = environment + m._config.list_size = 1 + for page in (0, -1, 99, 'bad'): + src = Source() + m.list_servers(src, page) + assert 'page_corrected' in str(src.replies[0]) + nav = src.replies[-1].to_json_object() + assert 'list 0' not in str(nav) and 'list 2' in str(nav) + assert str(nav).count('clickEvent') == 1 + src = Source() + m.list_servers(src, 2) + nav = str(src.replies[-1].to_json_object()) + assert nav.count('clickEvent') == 1 and 'list 1' in nav + + +@pytest.mark.parametrize('reason', ['occupied', 'unchecked', 'missing', 'invalid']) +def test_unavailable_reason_stays_visible(environment, reason): + m, _, Source = environment + from mount.constants import MOUNTABLE_CONFIG + target = Path(m.servers_as_list[1]) + config = target/MOUNTABLE_CONFIG + if reason == 'missing': + m._config.available_servers[1] = str(target/'absent') + elif reason == 'invalid': + config.write_text('{', encoding='utf-8') + else: + data = json.loads(config.read_text()) + data['occupied_by' if reason == 'occupied' else 'checked'] = 'other' if reason == 'occupied' else False + config.write_text(json.dumps(data)) + src = Source() + m.list_servers(src) + visible = '\n'.join(map(str, src.replies)) + assert 'flow.'+reason in visible + assert 'flow.switch' not in visible + + +def test_duplicate_names_use_private_exact_tokens(environment): + m, _, Source = environment + from test_commands_stats import command_root + from mount.constants import MOUNTABLE_CONFIG + first = Path(m.servers_as_list[1]) + second = first.parent/'other'/first.name + second.mkdir(parents=True) + (second/MOUNTABLE_CONFIG).write_bytes((first/MOUNTABLE_CONFIG).read_bytes()) + m._config.available_servers.append(str(second)) + src = Source() + m.list_servers(src) + serialized = '\n'.join(json.dumps(row.to_json_object()) if hasattr(row, 'to_json_object') else str(row) for row in src.replies) + assert str(first.parent) not in serialized and '#1' in serialized and '#2' in serialized + root = command_root(m) + for path in (str(first), str(second)): + root.execute(src, '!!mount switch '+m.target_token(path)) + assert m.vote.target == path + m.cancel(src, m.vote.request_id) + m.request_mount(src, 'slot:'+'0'*64) + assert m.vote is None + + +def test_untrusted_activity_falls_back_to_configured_order(environment): + m, _, Source = environment + from mount.constants import MOUNTABLE_CONFIG + current = Path(m.current_slot.path)/MOUNTABLE_CONFIG + data = json.loads(current.read_text()) + data['stats']['schema_version'] = 1 + current.write_text(json.dumps(data)) + target = Path(m.servers_as_list[1])/MOUNTABLE_CONFIG + data = json.loads(target.read_text()) + data['stats']['player_time_ns_v2'] = 999999 + target.write_text(json.dumps(data)) + m._config.list_order = 'activity' + src = Source() + m.list_servers(src) + text = '\n'.join(map(str, src.replies)) + assert 'activity_fallback' in text + assert text.index('current') < text.index('target with spaces') + + +@pytest.mark.parametrize('language', ['en_us', 'zh_cn']) +@pytest.mark.parametrize('mode', ['full', 'region']) +def test_rendered_language_scope_rules_before_buttons(environment, monkeypatch, language, mode): + from ruamel.yaml import YAML + import mount.MountManager as mm + from mount.constants import MOUNTABLE_CONFIG + m, server, Source = environment + translations = YAML().load((Path(__file__).resolve().parents[1]/'lang'/(language+'.yml')).read_text(encoding='utf-8'))['mount'] + def translate(key, **kwargs): + section, field = key.split('.') + return translations[section][field].format(**kwargs) + monkeypatch.setattr(mm, 'rtr', translate) + config = Path(m.current_slot.path)/MOUNTABLE_CONFIG + data = json.loads(config.read_text()) + data['reset_type'] = mode + config.write_text(json.dumps(data)) + m.request_reset(Source()) + bob = Source('Bob') + m.show_status(bob, m.vote.request_id) + yes = translations['flow']['yes'] + for text in (str(server.messages[-1]), str(bob.replies[-1])): + for word in (mode, 'world', 'template', 'playerdata', 'advancements', 'stats'): + assert word in text and text.index(word) < text.index(yes) + assert str(m.current_slot.path) not in text + broadcast = str(server.messages[-1]) + assert '100.0%' in broadcast and '3/3' in broadcast and '60' in broadcast + assert ('New arrivals excluded' if language == 'en_us' else '新加入不计入') in broadcast + assert ('departures do not lower' if language == 'en_us' else '离开不降低') in broadcast + print(language, mode, broadcast) From 7d8d83b2b6009b235aa7670aff5562d89f9111d2 Mon Sep 17 00:00:00 2001 From: 401U Date: Sun, 4 Oct 2026 12:16:30 +0800 Subject: [PATCH 3/4] test(401U-8): support synchronous MCDR 2.14 command callbacks Co-authored-by: multica-agent --- tests/test_commands_stats.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/test_commands_stats.py b/tests/test_commands_stats.py index 2574f95..baae37e 100644 --- a/tests/test_commands_stats.py +++ b/tests/test_commands_stats.py @@ -40,12 +40,16 @@ def command_root(m): node = holder[0] if hasattr(node, 'execute'): return node - # MCDR 2.16 separated traversal and callback invocation. Exercise both - # stages with its own synchronous invoker, without a mocked parser. - from mcdreforged.command.builder.callback import DirectCallbackInvoker + # MCDR 2.14 invokes callbacks synchronously and returns None. MCDR 2.16 + # returns executions requiring its own invoker. Use the real traversal + # result instead of treating every version without execute as 2.16. class Root: def execute(self,source,command): - for execution in node._entry_execute(source,command): + executions = node._entry_execute(source,command) + if executions is None: + return + from mcdreforged.command.builder.callback import DirectCallbackInvoker + for execution in executions: execution.scheduled_callback.invoke(DirectCallbackInvoker()) return Root() From 91bd3dcee4d1c606b974fdf4d46e463da2df924a Mon Sep 17 00:00:00 2001 From: 401U Date: Sun, 4 Oct 2026 12:38:48 +0800 Subject: [PATCH 4/4] docs(401U-8): align bilingual setup and behavior with implementation Co-authored-by: multica-agent --- README.md | 21 ++++++++++++----- README_en.md | 23 +++++++++++++------ docs/migration.md | 30 ++++++++++++++++-------- docs/verification.md | 54 +++++++++++++++++++++++++++++++++++++------- 4 files changed, 97 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 9721442..de25636 100644 --- a/README.md +++ b/README.md @@ -13,13 +13,13 @@ 1. 配置一个MCDR实例(包括MC服务端)并加入此插件, 将其启动 -2. 依照 (配置选项)[## 配置选项] 修改主体插件配置, **特别是自动检测挂载点的配置**, 然后重载此插件(使用指令`!!mount -r`即可) +2. 依照 [配置选项](#配置选项) 修改主体插件配置, **特别是自动检测挂载点的配置**, 然后重载此插件(使用指令`!!mount -r`即可,需要 MCDR 3 级权限) -3. 修改在第二步配置的`overwrite_name`文件, 建议添加服务器端口和RCON相关配置来获取一致的体验 +3. 修改在第二步配置的`overwrite_path`所指文件, 建议添加服务器端口和RCON相关配置来获取一致的体验 -4. 在第二步配置的自动检测目录中, 放入更多MC服务端实例, 然后可以游戏内输入`!!mount -l`查看并修改相关配置选项, 在修改完毕之后将`人工检查`项设置为`True` +4. 在第二步配置的自动检测目录中, 放入更多MC服务端实例,重载后输入`!!mount -l`查看。检查各实例的`mountable.json`;游戏内用`!!mount --config <完整路径>`查看或修改配置需要 MCDR 3 级权限。确认配置后将`checked`设置为`true` -5. 此时重新输入`!!mount -l` 即可看到人工检查通过的服务端处于可用状态, 挂载即可 +5. 重新输入`!!mount -l`查看可用状态;在线玩家可点击投票切换按钮发起投票,再用带会话 ID 的赞成按钮投票。达到门槛后才执行切换 6. 更多指令, 可在游戏内输入`!!mount`获取帮助信息 @@ -66,7 +66,8 @@ "occupied_by": "", // 此挂载点的重置路径, 空或者.代表无重置路径 "reset_path": "", - // 此挂载点的重置方法, full代表全部重置, region代表保留玩家信息(如跑酷记录) + // 仅替换reset_path模板提供的world/world_nether/world_the_end;缺失的世界不变 + // full替换所选整个世界;region仅保留主世界world内的playerdata/advancements/stats,下界/末地仍整世界替换 "reset_type": "full", // 专为此挂载点的mcdr插件目录, 使得每个挂载点可使用专有的插件, 空或者.代表无 "plugin_dir": "", @@ -82,4 +83,12 @@ ## 投票、备份与排序升级 -本分支将切换/重置统一为在线比例投票,新增授权者直接备份、指定名单本人确认强制操作与置顶/活跃度排序。旧配置安全迁移,详见 [迁移与命令说明](docs/migration.md) 和 [隔离验证说明](docs/verification.md)。 +安装包含这些功能的版本后,普通切换和重置均创建在线玩家投票:默认切换 60%、重置 100%,门槛为 `ceil(N×ratio)`;创建时冻结在线集合,发起者也须显式投票。首票不能更改,后来者不加入、离开不降低门槛;无人在线或快照不可信时拒绝发起。超时及同类投票终结后的冷却默认均为 60 秒,可配置。 + +备份权限由`backup_permission`控制,默认 MCDR 3 级;授权者点击即执行,无投票或二次确认。运行中的服务器完全停止后,复制配置的世界及其中玩家数据,再尝试启动;原已停机则保持停机。默认备份`world`、`world_nether`、`world_the_end`,可配置相对路径;不备份插件数据,不提供恢复命令或自动清理。副本校验成功与服务器恢复启动成功分别报告。 + +强制名单`force_players`默认空,控制台强制`force_console`默认关闭;名单不授予备份或管理取消权限。所有`force switch/reset`均须操作者本人在默认 60 秒内执行`force confirm `。确认前原投票继续;目标、配置或原会话变化会使摘要失效,不能抢占执行中的操作。备份和强制入口仅向授权者显示,手动输入也会检查权限。 + +列表默认配置顺序,置顶优先,可选可信活跃度排序;同分稳定,排序先于分页,不隐藏不可用项,统计不可信时明确降级。旧`<路径>`及`<路径> --confirm`创建切换投票;单独`--confirm`只查进度,`--reset/-rs`仍走重置投票,`!!m`别名可配置。重置失败可能留下部分替换,无自动回滚;跨实例文件占用检查不提供原子锁保证。 + +配置、命令及迁移细节见 [迁移与命令说明](docs/migration.md)。测试层级、历史失败和未验证范围见 [验证证据](docs/verification.md),其中测试结果不代表客户端或生产部署验收,也不表示该版本已正式发布。 diff --git a/README_en.md b/README_en.md index f126067..93dce58 100644 --- a/README_en.md +++ b/README_en.md @@ -13,13 +13,13 @@ 1. Deploy a MCDR instance(with MC server), and start it with this plugin -2. Edit the main config according to (Config)[## Config], then reload this plugin(by command `!!mount -r` or other command provided by MCDR) +2. Edit the main config according to [Config](#config), then reload this plugin (`!!mount -r` requires MCDR permission level 3) -3. Edit the file of `overwrite_name` which is configed at setp 2, Recommand to set up server port and rcon to get a consistent experience +3. Edit the file specified by `overwrite_path` in step 2. Setting server port and RCON options can provide a consistent experience -4. Add more MC server into the `servers_path` at step 2, then you can use `!!mount -l` to see them and edit their config according to (Config)[## Config], finally make sure set `checked` to true +4. Add more MC servers under `servers_path`, reload, and use `!!mount -l` to list them. Check each instance's `mountable.json`; viewing or editing configuration with `!!mount --config ` requires MCDR permission level 3. Set `checked` to `true` after checking the configuration -5. Type `!!mount -l` now, you can see available servers now, and then mount it +5. Type `!!mount -l` to check availability. An online player can click the vote-switch button to start a vote, then explicitly vote with its session-ID Yes button. Switching runs only after the threshold is reached 6. For more command, type `!!mount` in game to get help @@ -66,10 +66,11 @@ "occupied_by": "", // reset path for this server, '' and '.' means empty "reset_path": "", - // reset method, full for reset all, region for keep up player data(e.g. Parkour record) + // Only replace world/world_nether/world_the_end provided by reset_path; missing worlds stay unchanged + // full replaces entire selected worlds; region preserves only main-world playerdata/advancements/stats; selected Nether/End worlds are replaced entirely "reset_type": "full", // mcdr plugin dir for this server, '' and '.' means empty - "plugin_dir": "", + "plugin_dir": "", "stats": { // Stats for this server, will generate automaticaly } @@ -82,4 +83,12 @@ ## Voting, backup and ordering upgrade -Switch/reset now use online-player voting. Authorized users can back up directly; configured force users personally confirm their action. Lists support pinning and optional trusted activity ordering. See [migration and commands](docs/migration.md) and [isolated verification](docs/verification.md). +After installing a version containing these features, ordinary switch/reset commands create online-player votes: defaults are 60%/100%, with a threshold of `ceil(N×ratio)` and the online electorate frozen at creation. The initiator must vote explicitly. First votes cannot change; arrivals are excluded and departures do not lower the threshold. Empty or untrusted player snapshots refuse initiation. Timeout and cooldown after each vote of the same kind ends both default to 60 seconds and are configurable. + +`backup_permission` defaults to MCDR level 3. Authorized backup clicks execute immediately without voting or confirmation. A running server is fully stopped before copying configured worlds and their player data, then startup is attempted; an originally stopped server stays stopped. Defaults are `world`, `world_nether`, `world_the_end`, with configurable relative paths. Plugin data, restore commands and automatic retention cleanup are excluded. Verified-copy success and server-startup success are reported separately. + +`force_players` defaults to empty and `force_console` to false. Force names grant neither backup nor management-cancel permission. Every `force switch/reset` requires the operator's own `force confirm ` within 60 seconds by default. The old vote continues before confirmation; target, configuration or old-session changes invalidate the summary. Running operations cannot be preempted. Backup/force entries are visible only to authorized users, and manually typed commands also check authorization. + +Lists default to configured order, with pins first and optional trusted activity sorting. Ties are stable, sorting precedes pagination, unavailable entries remain visible, and untrusted statistics trigger an explicit fallback. Legacy `` and ` --confirm` create switch votes; bare `--confirm` only shows progress, `--reset/-rs` creates reset votes, and `!!m` is configurable. Failed resets may leave partial replacements with no automatic rollback; file-based cross-instance occupancy does not provide atomic locking. + +See [migration and commands](docs/migration.md) for configuration and migration details, and [verification evidence](docs/verification.md) for test layers, historical failures and unverified areas. Tests do not establish client or production acceptance or imply a formal release. diff --git a/docs/migration.md b/docs/migration.md index de3c19b..74c5b87 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -10,7 +10,8 @@ 切换默认 60%、重置默认 100%、60 秒超时。冻结在线分母,门槛向上取整,发起者 必须显式投票,一人一票且不能改票。加入不增员、离开不降低门槛;已经离线的玩家 不能继续投票。普通投票仅受信任玩家命令源可发起,控制台只看进度。 -同实例同类投票从上次终结起冷却 60 秒。 +同实例同类投票从上次终结起冷却 60 秒;比例、超时和冷却均可配置。 +无在线玩家或在线快照不可信时拒绝发起,不会自动通过。 运行中加载、重载及切服后查询 `list` 恢复完整在线集合。只支持 vanilla/bukkit 英文在线列表格式;超时、人数不匹配或其他 handler 安全拒绝投票,单次进出事件 @@ -33,7 +34,7 @@ | `force_players`, `force_console` | `[]`, `false`;按认证玩家名精确匹配,名单不附带其他权限 | | `force_timeout` | `60` 秒,所有强制操作本人确认有效期 | | `backup_root` | `../mount-backups`;须与实例、源世界和重置模板隔离 | -| `backup_worlds` | `world/world_nether/world_the_end`;可配置实例内相对路径 | +| `backup_worlds` | `["world", "world_nether", "world_the_end"]`;可配置相对当前 MC 服务器目录的路径 | | `stop_timeout`, `start_timeout` | `60`, `120` 秒 | 非法分页大小回退 15;非法比例、权限、计时或排序模式拒绝加载,修正配置后重载。 @@ -43,7 +44,8 @@ ## 操作命令 `!!mount switch <完整路径>` 支持空格。`reset` 发起当前服重置投票; -`vote yes|no` 投票,`status [id]` 查看,`cancel ` 由发起者或管理权限取消。 +`vote yes|no` 投票,`status [id]` 查看,`cancel ` 由发起者或达到 +`cancel_permission`(默认 MCDR 3 级)的管理者取消。 `list [page]` 先排序再分页,不可用目标保留显示。 授权者看到独立 `backup` 按钮,点击直接开始,无确认、无投票、不切换或重置。 @@ -55,13 +57,15 @@ 启动命令发出不等于启动完成,结果分别报告文件操作与真实启动状态。 备份结果仅向发起者及管理日志显示;掉线结果限本次运行期保存并在回服后补发。 -`force switch <完整路径>`、`force reset` 仅名单用户可用;均需 +`force switch <完整路径>`、`force reset` 默认仅名单玩家可用;控制台须启用 +`force_console`且具有 MCDR 4 级权限。均需 `force confirm ` 本人确认。确认摘要绑定操作者、完整目标、配置与原投票 ID。 确认前原投票继续;放弃、过期或原投票变化使摘要失效。确认不等待他人同意, 也不能抢占执行中的操作。 重置仅替换模板实际提供的默认三个世界。full 替换整个世界;region 仅保留 -主世界的 `playerdata/advancements/stats`,下界与末地整世界替换。重置不是备份, +主世界`world`内的 `playerdata`、`advancements`、`stats`,其余主世界内容以模板替换; +下界与末地整世界替换。模板缺失的世界保持原样,`backup_worlds`不改变重置范围。重置不是备份, 中途磁盘失败可能留下部分替换的世界,无自动回滚,应使用人工备份恢复。 卸载取消待投票/待确认,执行器协作收尾并恢复本次停下的服务器。 @@ -79,7 +83,9 @@ Use `switch ` (spaces supported), `reset`, `vote yes|no`, `statu electorate, rounded up; timeout and per-kind cooldown are 60 seconds. The initiator must vote explicitly. First votes are final; arrivals are excluded, departures do not lower the threshold, and offline players cannot cast new votes. Console -can inspect votes but cannot initiate or vote. +can inspect votes but cannot initiate or vote. Empty or untrusted snapshots refuse +initiation. Ratios, timeout and cooldown are configurable. Vote owners or users meeting +`cancel_permission` (default MCDR level 3) can cancel; force names grant no such management permission. Load/reload/startup queries `list`. Only supported vanilla/bukkit English output establishes a complete snapshot. Failure stays untrusted despite subsequent @@ -111,15 +117,19 @@ backup or management-cancel permission. Every `force switch/reset` requires pers until confirmation; changing/ending it invalidates the summary. Execution cannot be preempted. Force confirmation binds owner, target, configuration and exact old vote. -Reset replaces only worlds provided by the template. Full replaces whole worlds; -region preserves playerdata/advancements/stats only in the main world. Nether/End -are replaced entirely. Reset disk failures can leave partially replaced worlds; +Console force requires both `force_console=true` and MCDR permission level 4. + +Reset considers only `world`, `world_nether`, `world_the_end` provided by the template; +missing template worlds stay unchanged. Full replaces entire selected worlds; +region preserves only `playerdata`, `advancements`, `stats` inside the main `world`, +replacing its other contents from the template. Selected Nether/End worlds +are replaced entirely. `backup_worlds` does not change reset scope. Reset disk failures can leave partially replaced worlds; there is no automatic rollback. Keep an independent backup for manual recovery. Unload cancels pending sessions and lets an executor finish recovery. Reload during execution reuses that executor's manager; reload again after completion to load new instance configuration. File-based occupied_by retains its pre-existing cross-instance -TOCTOU limitation. Merge, release and production-world operations are outside this change. +TOCTOU limitation. ## 列表与投票展示补充 / List and vote presentation 列表正文使用服务器目录名;重名以配置顺序编号消歧。按钮使用完整 SHA-256 路径令牌,后台仍精确匹配配置路径,手动输入完整路径继续兼容。令牌目标移除后拒绝执行。正文区分置顶、当前、可用、占用、未检查及目录/配置失效;边界翻页不可点击,无效页码返回第 1 页。任一活跃度不可验证时,整体降级为置顶加配置顺序并说明原因,不隐藏不可用项。 diff --git a/docs/verification.md b/docs/verification.md index c5def22..e60483f 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -5,7 +5,42 @@ serialization, command nodes and RText, with the plugin/server-interface boundar replaced by a deterministic test server. All filesystem changes use disposable test directories. No production world was read or changed. -Final local results: **43 passed on MCDR 2.7.0; 43 passed on MCDR 2.16.0**. +## Current evidence record / 当前证据记录 + +As of 2026-10-04, the deployed MCDR version is confirmed as **2.14.0**. +At `7d8d83b2b6009b235aa7670aff5562d89f9111d2`, engineering ran the original +66 tests on MCDR 2.7.0, 2.14.0 and 2.16.0: each passed with exit code 0. +The QA-owned additional 11 assertions also passed on 2.14.0 with exit code 0. +The project manager independently reran the 2.14 original 66 plus QA 11 tests, +with test and outer-process exit codes 0; for 2.7/2.16 the manager reviewed the +engineering logs without independently rerunning them. Complete commands, +separate stdout/stderr, environments and exit codes are attached to 401U-8. + +Before that test-helper correction, `f981c3fe712d16fd387633ae34ac1b196439b5f4` +failed on 2.14 because the helper imported a 2.16-only callback module: +the original suite was 62 passed / 4 failed; the manager's suite including +QA's 11 additional assertions was 71 passed / 6 failed. This did not establish +a product-command failure. The helper now exercises 2.14's synchronous real +`_entry_execute` callbacks and retains 2.7's `execute` and 2.16's real invoker; +no parser mock, weakened assertions or conditional skips were introduced. + +QA's isolated real Java / MCDR 2.14 report contains evidence for 19 passing +assertions at the older `f981c3f` SHA. Product files are identical at `7d8d83b`, +so that evidence is reused, not described as a new-SHA Java rerun. This report +does not establish authenticated player/client, Paper or production-configuration +acceptance. Subsequent documentation-only revisions do not change that test SHA. +These evidence layers describe what was exercised, not release or deployment approval. + +中文:2026-10-04,部署版本确认为 MCDR 2.14.0。`7d8d83b` 工程回归三版本原66项各通过, +2.14 原QA11项也通过;经理独立复跑2.14原66+QA11项,两层退出码0。 +2.7/2.16经理仅核对工程日志。真实Java19项来自旧`f981c3f`且产品文件未变,复用该证据, +本轮未重跑Java。认证玩家/客户端、Paper及生产配置仍未验证;历史异常退出原因未定位, +CI artifact v3及凭据workflow权限风险保留。详细记录在401U-8/401U-9任务附件中。 + +## Historical initial implementation / 初次实现历史记录 + +At `13c36adcd18ea15e531e61d65914bae7bda946aa` (2026-10-03 UTC), initial local results were +**43 passed on MCDR 2.7.0; 43 passed on MCDR 2.16.0**. The isolated real-MCDR subprocess scenario passed on both versions, including full/region reset and reload during execution. The 2.7 environment emitted its existing pkg_resources/enum deprecation warnings; 2.16 emitted none in the unit suite. @@ -59,8 +94,9 @@ tree on failure. `isolated-mcdr.log` is generated locally and intentionally untr ## Limits / 待独立验收 -- Real Java vanilla/Paper/Bukkit output, live authentication and game-world validity - have not been verified. The deployed MCDR version is unknown; test it before rollout. +- The initial implementation had no real Java evidence or confirmed deployment + version. The later isolated Java/MCDR 2.14 evidence is scoped above; authenticated + player/client, Paper and production configuration remain unverified. - Bukkit and localized/custom handler snapshot formats need deployment validation; unsupported/unknown snapshots refuse voting safely. A `list` response has no nonce; queries never overlap within one server session. Expired/unloaded queries must @@ -76,14 +112,14 @@ tree on failure. `isolated-mcdr.log` is generated locally and intentionally untr recovery/finalization, and a process-wide owner prevents a fresh load starting a second manager. In-memory backup-result delivery does not survive process exit. -中文:自动测试与真实 MCDR 进程的可控服务端模拟通过,不等于真实 Java 游戏服务 -验收。实际部署版本、Bukkit/本地化输出及真实游戏存档须由阶段 3 独立验证。 +中文:初次实现的自动测试与真实 MCDR 进程的可控服务端模拟通过,不等于真实 Java 游戏服务 +验收。后续2.14隔离Java证据见上文;Bukkit/本地化输出、客户端与生产配置仍须独立验证。 未执行合并、正式发布或生产存档操作。 -## 401U-8 独立验收修订 +## Historical acceptance revision / 独立验收修订历史记录 固定旧 SHA `13c36adcd18ea15e531e61d65914bae7bda946aa` 运行独立补验得到 8 passed / 4 failed;保留原脚本全部 12 项功能断言,未删减要求。修复广播/状态重置范围、创建投票门槛和列表展示。新增 11 项语义测试覆盖非法页码及两端导航、四种不可用原因、重名精确目标与路径隐藏、活跃度整体降级、中英文 full/region 文本在 Yes 前展示。 -Windows / Python 3.11,MCDR 2.7.0 和 2.16.0 各 66 passed(原 43 + 独立 12 + 新增 11)。2.7 有两项依赖弃用警告。复现: +2026-10-03 UTC 修订至`f981c3fe712d16fd387633ae34ac1b196439b5f4`,Windows / Python 3.11,MCDR 2.7.0 和 2.16.0 各 66 passed(原 43 + 独立 12 + 新增 11)。2.7 有两项依赖弃用警告。复现: ```text uv run --python 3.11 --with-requirements requirements-test-2.7.txt python -m pytest -q tests @@ -96,4 +132,6 @@ uv run --python 3.11 --with-requirements requirements-test.txt --with mcdreforge 四个隔离进程流程均有成功通过记录,覆盖真实 MCDR 插件加载、列表/边界按钮、玩家快照、切换比例、含空格路径切服、其他选民的重置 scope/status、取消、备份、full/region 重置、执行中 reload 和退出。最终批次中 2.7 中文流程一次完成动作并输出 bye 后非零退出,随后两次复跑均成功;该退出偶发尚未定位,不宣称进程稳定性已证明。服务端仍为 Python vanilla 输出模拟,不是 Java。中文测试环境显式配置 UTF-8 编解码,避免 Windows 默认 GBK 与模拟进程 UTF-8 不匹配;真实部署编码未验证。`rendered-en_us.txt` / `rendered-zh_cn.txt` 是实际 tellraw JSON 平展后的纯文本证据(换行显示为 ` | `),不代表客户端截图。单测另读取实际语言 YAML 并检查 RText 内容与按钮先后。 -真实 Java 服务端、认证客户端、EULA 设置及实际部署 MCDR 版本未提供,仍未验证。原跨实例 TOCTOU、重置部分失败须人工恢复及 CI artifact v3/workflow 权限限制保留。未发布、合并、接受 EULA 或操作生产世界。 +在该次修订运行时,真实 Java 服务端、认证客户端、EULA 设置及实际部署 MCDR 版本尚未提供;这描述当时的证据边界,后续2.14隔离Java记录见上文。原跨实例 TOCTOU、重置部分失败须人工恢复及 CI artifact v3/workflow 权限限制保留。该次工程运行未发布、合并、接受 EULA 或操作生产世界。 + +历史2.7中文流程的bye后非零退出仍未定位:原失败样本的完整stdout/stderr、外层及MCDR退出码、精确时序和现场SHA没有找回。残留日志与后续成功重跑不证明该失败已修复。原隔离脚本会覆盖同名日志,复现时应另外保存每次运行的stdout、stderr及外层/MCDR退出码;后续测试捕获器的改进不能补回历史证据。